1package cmd
2
3import (
4 "bytes"
5 "io"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "testing"
11
12 "github.com/spf13/cobra"
13)
14
15func TestParsePassthrough(t *testing.T) {
16 t.Parallel()
17
18 tests := []struct {
19 name string
20 args []string
21 want map[string][]string
22 }{
23 {
24 name: "no passthrough",
25 args: nil,
26 want: nil,
27 },
28 {
29 name: "flag with value",
30 args: []string{"--repo", "/repo"},
31 want: map[string][]string{
32 "repo": {"/repo"},
33 },
34 },
35 {
36 name: "repeated and equals flags",
37 args: []string{"--tag=daily", "--tag", "weekly"},
38 want: map[string][]string{
39 "tag": {"daily", "weekly"},
40 },
41 },
42 {
43 name: "boolean flags",
44 args: []string{"-v", "--json"},
45 want: map[string][]string{
46 "v": nil,
47 "json": nil,
48 },
49 },
50 {
51 name: "positional arguments become _arguments",
52 args: []string{"/src", "/dst"},
53 want: map[string][]string{
54 overrideArgumentsKey: {"/src", "/dst"},
55 },
56 },
57 {
58 name: "mixed flags and positional arguments",
59 args: []string{"--repo", "/repo", "/src", "/dst"},
60 want: map[string][]string{
61 "repo": {"/repo"},
62 overrideArgumentsKey: {"/src", "/dst"},
63 },
64 },
65 {
66 name: "double-dash preserves literal arguments",
67 args: []string{"--repo", "/repo", "--", "--literal", "path"},
68 want: map[string][]string{
69 "repo": {"/repo"},
70 overrideArgumentsKey: {"--literal", "path"},
71 },
72 },
73 {
74 name: "single dash is positional argument",
75 args: []string{"-"},
76 want: map[string][]string{
77 overrideArgumentsKey: {"-"},
78 },
79 },
80 {
81 name: "flag without value before another flag is boolean",
82 args: []string{"--host", "--json"},
83 want: map[string][]string{
84 "host": nil,
85 "json": nil,
86 },
87 },
88 }
89
90 for _, tt := range tests {
91 tt := tt
92 t.Run(tt.name, func(t *testing.T) {
93 t.Parallel()
94
95 got := parsePassthrough(tt.args)
96 if !reflect.DeepEqual(got, tt.want) {
97 t.Fatalf("overrides mismatch: got %#v, want %#v", got, tt.want)
98 }
99 })
100 }
101}
102
103func TestRunCommandAppliesRootFlagsAndPassthrough(t *testing.T) {
104 configFile := setupCommandConfig(t)
105 setRootFlagValuesForTest(t, "home@cloud", true, configFile)
106 t.Setenv("KELD_CONFIG_FILE", "")
107 t.Setenv("KELD_DRYRUN", "")
108
109 backup := lookupSubcommand(t, "backup")
110 out, err := captureStdout(t, func() error {
111 return runCommand("backup", backup, []string{"--tag", "daily", "/src"}, nil, nil)
112 })
113 if err != nil {
114 t.Fatalf("runCommand returned error: %v", err)
115 }
116
117 if got := os.Getenv("KELD_CONFIG_FILE"); got != configFile {
118 t.Fatalf("KELD_CONFIG_FILE mismatch: got %q, want %q", got, configFile)
119 }
120 if got := os.Getenv("KELD_DRYRUN"); got != "1" {
121 t.Fatalf("KELD_DRYRUN mismatch: got %q, want %q", got, "1")
122 }
123
124 for _, want := range []string{"\"backup\"", "\"--tag\" \"daily\"", "\"/src\""} {
125 if !strings.Contains(out, want) {
126 t.Fatalf("dry-run output missing %q:\n%s", want, out)
127 }
128 }
129}
130
131func TestRunCommandRejectsUnknownPreset(t *testing.T) {
132 configFile := setupCommandConfig(t)
133 setRootFlagValuesForTest(t, "missing", true, configFile)
134
135 backup := lookupSubcommand(t, "backup")
136 err := runCommand("backup", backup, nil, nil, nil)
137 if err == nil {
138 t.Fatal("expected unknown preset error")
139 }
140 if !strings.Contains(err.Error(), "unknown preset") {
141 t.Fatalf("expected unknown preset error, got: %v", err)
142 }
143}
144
145func TestSubcommandHelpShowsCobraHelp(t *testing.T) {
146 backup := lookupSubcommand(t, "backup")
147 buf := &bytes.Buffer{}
148 backup.SetOut(buf)
149 backup.SetErr(buf)
150 t.Cleanup(func() {
151 backup.SetOut(os.Stdout)
152 backup.SetErr(os.Stderr)
153 })
154
155 err := backup.RunE(backup, []string{"--help"})
156 if err != nil {
157 t.Fatalf("backup --help returned error: %v", err)
158 }
159
160 helpText := buf.String()
161 if !strings.Contains(helpText, "Usage:") {
162 t.Fatalf("help output missing Usage section:\n%s", helpText)
163 }
164 if !strings.Contains(helpText, "--exclude") {
165 t.Fatalf("help output missing backup flag listing:\n%s", helpText)
166 }
167 if !strings.Contains(helpText, "--repo") {
168 t.Fatalf("help output missing inherited global flags:\n%s", helpText)
169 }
170}
171
172func setupCommandConfig(t *testing.T) string {
173 t.Helper()
174
175 dir := t.TempDir()
176 cfg := filepath.Join(dir, "config.toml")
177 err := os.WriteFile(cfg, []byte(`
178[global]
179repo = "/repos/default"
180
181["home@"]
182tag = "home"
183
184["@cloud"]
185repo = "/repos/cloud"
186
187[archive]
188json = true
189`), 0o600)
190 if err != nil {
191 t.Fatalf("writing fixture: %v", err)
192 }
193 t.Setenv("HOME", dir)
194
195 return cfg
196}
197
198func setRootFlagValuesForTest(t *testing.T, preset string, showCommand bool, configFile string) {
199 t.Helper()
200
201 prevPreset, prevShowCommand, prevConfigFile := flagPreset, flagShowCmd, flagConfigFile
202 flagPreset = preset
203 flagShowCmd = showCommand
204 flagConfigFile = configFile
205 t.Cleanup(func() {
206 flagPreset = prevPreset
207 flagShowCmd = prevShowCommand
208 flagConfigFile = prevConfigFile
209 })
210}
211
212func lookupSubcommand(t *testing.T, name string) *cobra.Command {
213 t.Helper()
214
215 for _, cmd := range rootCmd.Commands() {
216 if cmd.Name() == name {
217 return cmd
218 }
219 }
220 t.Fatalf("subcommand %q not found", name)
221 return nil
222}
223
224func captureStdout(t *testing.T, run func() error) (string, error) {
225 t.Helper()
226
227 oldStdout := os.Stdout
228 reader, writer, err := os.Pipe()
229 if err != nil {
230 t.Fatalf("creating stdout pipe: %v", err)
231 }
232 os.Stdout = writer
233 t.Cleanup(func() {
234 os.Stdout = oldStdout
235 })
236
237 runErr := run()
238 _ = writer.Close()
239
240 out, readErr := io.ReadAll(reader)
241 if readErr != nil {
242 t.Fatalf("reading stdout: %v", readErr)
243 }
244 _ = reader.Close()
245
246 return string(out), runErr
247}
248
249func TestValidatePreset(t *testing.T) {
250 // Set up a config fixture with known presets.
251 dir := t.TempDir()
252 cfg := filepath.Join(dir, "config.toml")
253 err := os.WriteFile(cfg, []byte(`
254[global]
255verbose = true
256
257["music@"]
258tag = "music"
259
260["@hetzner"]
261repo = "sftp:hetzner"
262
263["@b2"]
264repo = "s3:b2"
265
266[archive]
267json = true
268`), 0o600)
269 if err != nil {
270 t.Fatalf("writing fixture: %v", err)
271 }
272 t.Setenv("KELD_CONFIG_FILE", cfg)
273 t.Setenv("HOME", dir)
274
275 tests := []struct {
276 name string
277 preset string
278 wantErr bool
279 }{
280 {name: "valid plain preset", preset: "archive", wantErr: false},
281 {name: "valid composite preset", preset: "music@hetzner", wantErr: false},
282 {name: "valid bare suffix", preset: "@b2", wantErr: false},
283 {name: "unknown preset", preset: "nope", wantErr: true},
284 {name: "unknown composite", preset: "photos@hetzner", wantErr: true},
285 }
286
287 for _, tt := range tests {
288 t.Run(tt.name, func(t *testing.T) {
289 err := validatePreset(tt.preset)
290 if (err != nil) != tt.wantErr {
291 t.Fatalf("validatePreset(%q): got err=%v, wantErr=%v", tt.preset, err, tt.wantErr)
292 }
293 })
294 }
295}
296
297func TestValidatePresetNoConfig(t *testing.T) {
298 // Point at an empty config so there are no presets at all.
299 dir := t.TempDir()
300 cfg := filepath.Join(dir, "config.toml")
301 if err := os.WriteFile(cfg, []byte("[global]\n"), 0o600); err != nil {
302 t.Fatalf("writing fixture: %v", err)
303 }
304 t.Setenv("KELD_CONFIG_FILE", cfg)
305 t.Setenv("HOME", dir)
306
307 err := validatePreset("anything")
308 if err == nil {
309 t.Fatal("expected error for unknown preset with empty config")
310 }
311 if !strings.Contains(err.Error(), "no presets defined") {
312 t.Fatalf("expected 'no presets defined' message, got: %v", err)
313 }
314}