From ac0adc17a6db03f6dacae2e77b30b386fe2b91f3 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 13 Mar 2026 14:38:30 -0600 Subject: [PATCH] Generate usable preset names from config fragments Rework presetsFrom() to combine prefix@ and @suffix sections into invocable preset names rather than returning raw TOML section keys. Expose Prefixes() and Suffixes() for shell completion. Completions now list prefixes first and complete suffixes after typing prefix@. Validate preset names on the non-interactive CLI path so unknown presets fail fast with a clear error listing available options. Map the 'repository' config key to restic's actual --repo flag. Quote every argument in dry-run output so arg boundaries are unambiguous when values contain spaces. --- cmd/completions.go | 76 ++++++++++++++---- cmd/completions_test.go | 96 ++++++++++++++++++---- cmd/root.go | 23 ++++++ cmd/root_test.go | 70 ++++++++++++++++ internal/config/config.go | 12 ++- internal/config/presets.go | 136 +++++++++++++++++++++++++++++--- internal/config/presets_test.go | 119 +++++++++++++++++++++++++++- internal/restic/exec.go | 18 ++++- internal/restic/exec_test.go | 4 +- 9 files changed, 510 insertions(+), 44 deletions(-) diff --git a/cmd/completions.go b/cmd/completions.go index 940e18983c7632236f10eb95528280870c3b8b20..73a7548eeebcddc6f5a3269badcea9756016b04c 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -2,6 +2,7 @@ package cmd import ( "slices" + "strings" "github.com/spf13/cobra" @@ -36,9 +37,13 @@ func init() { // It examines how many positional (non-flag) arguments have already been // provided and offers: // -// - 0 positionals so far → presets (from TOML config) + known commands -// - 1 positional so far → if it's a preset, offer commands; if it's -// already a command, no more positional completions +// - 0 positionals so far → prefixes, composite presets, bare suffixes, +// plain presets, and known commands +// - 1 positional so far → +// - if the word being completed contains "@", offer matching +// suffix completions (e.g. "home@" → "home@cloud", "home@nas") +// - if it's a preset, offer commands +// - if it's already a command, no more positional completions // - 2+ positionals → no further positional completions // // When the current word starts with "-", pika's own flags are offered instead. @@ -55,6 +60,11 @@ func completeArgs(_ *cobra.Command, args []string, toComplete string) ([]string, switch positionals { case 0: + // If the user is typing something with "@", complete with + // matching composite presets (prefix + suffix combos). + if strings.Contains(toComplete, "@") { + return completeSuffix(toComplete), cobra.ShellCompDirectiveNoFileComp + } // Nothing committed yet — offer presets and commands. return presetsAndCommands(), cobra.ShellCompDirectiveNoFileComp case 1: @@ -72,25 +82,61 @@ func completeArgs(_ *cobra.Command, args []string, toComplete string) ([]string, } // presetsAndCommands merges config presets with the known command list, -// deduplicating any overlap. +// deduplicating any overlap. Prefixes (e.g. "home@") come first so tab +// completion can chain into suffix selection, followed by composite and +// plain presets, bare suffixes, then commands. func presetsAndCommands() []string { + prefixes := config.Prefixes() presets := config.Presets() - seen := make(map[string]struct{}, len(presets)+len(knownCommands)) - out := make([]string, 0, len(presets)+len(knownCommands)) - for _, p := range presets { - if _, ok := seen[p]; ok { - continue + seen := make(map[string]struct{}, len(prefixes)+len(presets)+len(knownCommands)) + out := make([]string, 0, len(prefixes)+len(presets)+len(knownCommands)) + + add := func(s string) { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + out = append(out, s) } - seen[p] = struct{}{} - out = append(out, p) } + + // 1. Prefixes first (enable chained suffix completion). + for _, p := range prefixes { + add(p) + } + + // 2. Full presets (plain, composites, bare suffixes — in the order + // Presets() returns them). + for _, p := range presets { + add(p) + } + + // 3. Commands last. for _, c := range knownCommands { - if _, ok := seen[c]; ok { - continue + add(c) + } + + return out +} + +// completeSuffix generates completions when the user has typed a prefix +// containing "@". For example, typing "home@" offers "home@cloud", +// "home@nas", etc. by combining the typed prefix with known suffixes. +func completeSuffix(typed string) []string { + idx := strings.Index(typed, "@") + if idx < 0 { + return nil + } + + prefix := typed[:idx+1] // e.g. "home@" + suffixes := config.Suffixes() + + out := make([]string, 0, len(suffixes)) + for _, sfx := range suffixes { + // sfx is e.g. "@cloud"; combine as "home@cloud". + combo := prefix[:len(prefix)-1] + sfx + if strings.HasPrefix(combo, typed) { + out = append(out, combo) } - seen[c] = struct{}{} - out = append(out, c) } return out } diff --git a/cmd/completions_test.go b/cmd/completions_test.go index 0cbb75681c79f25d8d5b89d04554ff0e40dc9ea2..7812757a319b897a15c066930811605593feef4b 100644 --- a/cmd/completions_test.go +++ b/cmd/completions_test.go @@ -21,14 +21,17 @@ func setupCompletionConfig(t *testing.T) { [global] verbose = true -[home] -repo = "/repos/home" +["home@"] +host = "laptop" -["home@cloud"] +["@cloud"] repo = "/repos/cloud" ["@nas"] repo = "/repos/nas" + +[archive] +json = true `), 0o600) if err != nil { t.Fatalf("writing fixture config: %v", err) @@ -45,27 +48,51 @@ func TestCompleteArgsNoArgs(t *testing.T) { t.Fatalf("expected NoFileComp directive, got %v", directive) } - // Should contain both presets and commands. + // Ordering: prefixes first, then plain presets, composite presets, + // bare suffixes, then commands. has := make(map[string]bool) for _, c := range completions { has[c] = true } - for _, want := range []string{"home", "home@cloud", "@nas", "backup", "restore", "snapshots"} { + for _, want := range []string{ + "home@", // prefix + "archive", // plain preset + "home@cloud", // composite + "home@nas", // composite + "@cloud", // bare suffix + "@nas", // bare suffix + "backup", // command + "restore", // command + "snapshots", // command + } { if !has[want] { t.Errorf("missing expected completion %q in %v", want, completions) } } + // "global" must not appear. if has["global"] { t.Errorf("completions should not include 'global', got %v", completions) } + + // Verify ordering: "home@" (prefix) should appear before "home@cloud" + // (composite) and before "backup" (command). + idxPrefix := indexOf(completions, "home@") + idxComposite := indexOf(completions, "home@cloud") + idxCommand := indexOf(completions, "backup") + if idxPrefix >= idxComposite { + t.Errorf("prefix 'home@' (idx %d) should come before composite 'home@cloud' (idx %d)", idxPrefix, idxComposite) + } + if idxComposite >= idxCommand { + t.Errorf("composite 'home@cloud' (idx %d) should come before command 'backup' (idx %d)", idxComposite, idxCommand) + } } func TestCompleteArgsAfterPreset(t *testing.T) { setupCompletionConfig(t) - completions, directive := completeArgs(nil, []string{"home"}, "") + completions, directive := completeArgs(nil, []string{"home@cloud"}, "") if directive != cobra.ShellCompDirectiveNoFileComp { t.Fatalf("expected NoFileComp, got %v", directive) } @@ -90,7 +117,7 @@ func TestCompleteArgsAfterCommand(t *testing.T) { func TestCompleteArgsAfterPresetAndCommand(t *testing.T) { setupCompletionConfig(t) - completions, directive := completeArgs(nil, []string{"home", "backup"}, "") + completions, directive := completeArgs(nil, []string{"home@cloud", "backup"}, "") if directive != cobra.ShellCompDirectiveNoFileComp { t.Fatalf("expected NoFileComp, got %v", directive) } @@ -132,8 +159,39 @@ func TestCompleteArgsSkipsFlags(t *testing.T) { if !has["backup"] { t.Errorf("expected commands after flag+value, got %v", completions) } - if !has["home"] { - t.Errorf("expected presets after flag+value, got %v", completions) + if !has["home@"] { + t.Errorf("expected prefix 'home@' after flag+value, got %v", completions) + } +} + +func TestCompleteArgsSuffixCompletion(t *testing.T) { + setupCompletionConfig(t) + + // User has typed "home@" and pressed tab — should get suffix combos. + completions, directive := completeArgs(nil, nil, "home@") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp, got %v", directive) + } + + sort.Strings(completions) + want := []string{"home@cloud", "home@nas"} + if !reflect.DeepEqual(completions, want) { + t.Fatalf("suffix completions mismatch: got %v, want %v", completions, want) + } +} + +func TestCompleteArgsSuffixPartialCompletion(t *testing.T) { + setupCompletionConfig(t) + + // User has typed "home@c" — only "@cloud" matches. + completions, directive := completeArgs(nil, nil, "home@c") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp, got %v", directive) + } + + want := []string{"home@cloud"} + if !reflect.DeepEqual(completions, want) { + t.Fatalf("partial suffix completions mismatch: got %v, want %v", completions, want) } } @@ -147,11 +205,11 @@ func TestCountPositionals(t *testing.T) { }{ {name: "empty", args: nil, want: 0}, {name: "one positional", args: []string{"backup"}, want: 1}, - {name: "two positionals", args: []string{"home", "backup"}, want: 2}, + {name: "two positionals", args: []string{"home@cloud", "backup"}, want: 2}, {name: "flag with value", args: []string{"--repo", "/repo"}, want: 0}, {name: "flag=value", args: []string{"--repo=/repo"}, want: 0}, - {name: "mixed", args: []string{"--repo", "/repo", "home", "backup"}, want: 2}, - {name: "double dash", args: []string{"home", "--", "a", "b"}, want: 3}, + {name: "mixed", args: []string{"--repo", "/repo", "home@cloud", "backup"}, want: 2}, + {name: "double dash", args: []string{"home@cloud", "--", "a", "b"}, want: 3}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -174,9 +232,9 @@ func TestIsKnownCommand(t *testing.T) { }{ {name: "empty", args: nil, want: false}, {name: "command first", args: []string{"backup"}, want: true}, - {name: "preset first", args: []string{"home"}, want: false}, + {name: "preset first", args: []string{"home@cloud"}, want: false}, {name: "flag then command", args: []string{"--repo", "/repo", "backup"}, want: true}, - {name: "flag then preset", args: []string{"--repo", "/repo", "home"}, want: false}, + {name: "flag then preset", args: []string{"--repo", "/repo", "home@cloud"}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -188,3 +246,13 @@ func TestIsKnownCommand(t *testing.T) { }) } } + +// indexOf returns the position of s in slice, or -1 if not found. +func indexOf(slice []string, s string) int { + for i, v := range slice { + if v == s { + return i + } + } + return -1 +} diff --git a/cmd/root.go b/cmd/root.go index 0b4da439ebd749fbcecfe40de250ef0aafac96e1..3540fa558069a774589cce8187fbc94a81de61ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -88,6 +88,14 @@ var rootCmd = &cobra.Command{ command = selected } + // Validate the preset early when running non-interactively so the + // user gets a clear error instead of a silent empty config. + if !interactive && preset != "" { + if err := validatePreset(preset); err != nil { + return err + } + } + overrides := parsePassthrough(passthrough) // When launched interactively, prompt for a preset and any @@ -312,6 +320,21 @@ func runMenu() (string, error) { return model.Choice(), nil } +// validatePreset checks that the given preset name matches one of the usable +// presets derived from the config. Returns a descriptive error if not found. +func validatePreset(preset string) error { + known := config.Presets() + for _, p := range known { + if p == preset { + return nil + } + } + if len(known) == 0 { + return fmt.Errorf("unknown preset %q (no presets defined in config)", preset) + } + return fmt.Errorf("unknown preset %q; available presets: %s", preset, strings.Join(known, ", ")) +} + // promptPreset shows an interactive preset selector when presets are defined // in the config. Returns "" (global-only) if no presets exist. func promptPreset() (string, error) { diff --git a/cmd/root_test.go b/cmd/root_test.go index ce4c36c6eec59e97ff9971ab648b571642735c44..0f1729d9ec9de747a32b5a7255d686928d7d0fde 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,7 +1,10 @@ package cmd import ( + "os" + "path/filepath" "reflect" + "strings" "testing" ) @@ -221,3 +224,70 @@ func equalStringSlices(a, b []string) bool { } return reflect.DeepEqual(a, b) } + +func TestValidatePreset(t *testing.T) { + // Set up a config fixture with known presets. + dir := t.TempDir() + cfg := filepath.Join(dir, "config.toml") + err := os.WriteFile(cfg, []byte(` +[global] +verbose = true + +["music@"] +tag = "music" + +["@hetzner"] +repo = "sftp:hetzner" + +["@b2"] +repo = "s3:b2" + +[archive] +json = true +`), 0o600) + if err != nil { + t.Fatalf("writing fixture: %v", err) + } + t.Setenv("PIKA_CONFIG_FILE", cfg) + t.Setenv("HOME", dir) + + tests := []struct { + name string + preset string + wantErr bool + }{ + {name: "valid plain preset", preset: "archive", wantErr: false}, + {name: "valid composite preset", preset: "music@hetzner", wantErr: false}, + {name: "valid bare suffix", preset: "@b2", wantErr: false}, + {name: "unknown preset", preset: "nope", wantErr: true}, + {name: "unknown composite", preset: "photos@hetzner", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePreset(tt.preset) + if (err != nil) != tt.wantErr { + t.Fatalf("validatePreset(%q): got err=%v, wantErr=%v", tt.preset, err, tt.wantErr) + } + }) + } +} + +func TestValidatePresetNoConfig(t *testing.T) { + // Point at an empty config so there are no presets at all. + dir := t.TempDir() + cfg := filepath.Join(dir, "config.toml") + if err := os.WriteFile(cfg, []byte("[global]\n"), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + t.Setenv("PIKA_CONFIG_FILE", cfg) + t.Setenv("HOME", dir) + + err := validatePreset("anything") + if err == nil { + t.Fatal("expected error for unknown preset with empty config") + } + if !strings.Contains(err.Error(), "no presets defined") { + t.Fatalf("expected 'no presets defined' message, got: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 91c09880698864943fa720ac0a53d317ecb2a723..96754541dd56deaaac2370195393f3f09033160a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -328,9 +328,19 @@ func assemble(merged map[string]any, command string, environ map[string]string, return rc } +// keyAliases maps TOML config key names that don't match restic's actual CLI +// flag names. For example, "repository" is a natural config key but restic's +// flag is "--repo". +var keyAliases = map[string]string{ + "repository": "repo", +} + // flagName returns the CLI flag form of a key: single-char keys get "-k", -// longer keys get "--key". +// longer keys get "--key". Known aliases are resolved first. func flagName(key string) string { + if alias, ok := keyAliases[key]; ok { + key = alias + } if len(key) == 1 { return "-" + key } diff --git a/internal/config/presets.go b/internal/config/presets.go index 142b87cf39999c0dfdc19703029bd7e7c48c8501..2a863c72d2d818c23cf88dde310feacc39c4f86c 100644 --- a/internal/config/presets.go +++ b/internal/config/presets.go @@ -2,30 +2,148 @@ package config import ( "sort" + "strings" ) -// Presets loads all discovered config files and returns the distinct preset -// names found as top-level TOML sections. The "global" section is excluded -// since it provides shared defaults, not a user-selectable preset. +// Presets loads all discovered config files and returns the usable preset +// names derived from the top-level TOML sections. Raw section fragments +// like "@cloud" or "home@" are combined into invocable presets like +// "home@cloud". func Presets() []string { files := DiscoverFiles() return presetsFrom(files) } -// presetsFrom is the testable core of Presets. +// presetsFrom is the testable core of Presets. It categorises top-level +// sections and generates the list of presets a user can actually invoke. +// +// The returned list is ordered: plain presets first, then prefix@suffix +// combinations, then bare suffixes. Within each group, entries are sorted +// alphabetically. func presetsFrom(files []string) []string { raw, err := loadFiles(files) if err != nil { return nil } - presets := make([]string, 0, len(raw)) + var ( + plain []string // no "@" — e.g. "archive" + prefixes []string // ends with "@" — e.g. "home@" + suffixes []string // starts with "@" — e.g. "@cloud" + full = map[string]bool{} // both sides — e.g. "home@cloud" + ) + for key := range raw { - if key == "global" { + if key == "global" || key == "vars" { + continue + } + // Sub-sections like "home.backup" appear as nested maps under + // their parent; they are never top-level keys themselves after + // TOML parsing, so this guard is just defensive. + if strings.Contains(key, ".") { continue } - presets = append(presets, key) + + switch { + case strings.HasPrefix(key, "@"): + suffixes = append(suffixes, key) + case strings.HasSuffix(key, "@"): + prefixes = append(prefixes, key) + case strings.Contains(key, "@"): + full[key] = true + default: + plain = append(plain, key) + } + } + + sort.Strings(plain) + sort.Strings(prefixes) + sort.Strings(suffixes) + + seen := make(map[string]bool) + var out []string + + add := func(s string) { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + + // 1. Plain presets. + for _, p := range plain { + add(p) + } + + // 2. Prefix × suffix combinations. + for _, pfx := range prefixes { + for _, sfx := range suffixes { + add(pfx[:len(pfx)-1] + sfx) // "home@" + "@cloud" → "home@cloud" + } + } + + // 3. Explicit full split presets that weren't already generated. + fullSorted := make([]string, 0, len(full)) + for k := range full { + fullSorted = append(fullSorted, k) + } + sort.Strings(fullSorted) + for _, f := range fullSorted { + add(f) + } + + // 4. Bare suffixes (usable with just global defaults). + for _, sfx := range suffixes { + add(sfx) + } + + return out +} + +// Prefixes returns the distinct prefix fragments (sections ending with "@") +// found in the config. These are suitable for tab-completion of the first +// positional argument. +func Prefixes() []string { + files := DiscoverFiles() + return prefixesFrom(files) +} + +func prefixesFrom(files []string) []string { + raw, err := loadFiles(files) + if err != nil { + return nil + } + + var out []string + for key := range raw { + if strings.HasSuffix(key, "@") { + out = append(out, key) + } + } + sort.Strings(out) + return out +} + +// Suffixes returns the distinct suffix fragments (sections starting with "@") +// found in the config. These are suitable for completing the part after "@" +// when the user has typed a prefix. +func Suffixes() []string { + files := DiscoverFiles() + return suffixesFrom(files) +} + +func suffixesFrom(files []string) []string { + raw, err := loadFiles(files) + if err != nil { + return nil + } + + var out []string + for key := range raw { + if strings.HasPrefix(key, "@") { + out = append(out, key) + } } - sort.Strings(presets) - return presets + sort.Strings(out) + return out } diff --git a/internal/config/presets_test.go b/internal/config/presets_test.go index 89b15e715e05aa588f9adf02786f0227eab0820a..8c3ccde7cd38cf282c679c912e28c0eb2921a706 100644 --- a/internal/config/presets_test.go +++ b/internal/config/presets_test.go @@ -50,8 +50,89 @@ func TestPresetsFrom(t *testing.T) { got := presetsFrom([]string{path}) - // We expect every top-level section except "global", sorted. - want := []string{"@cloud", "archive", "home", "home@", "home@cloud", "vars"} + // Plain presets first, then prefix×suffix combos, then explicit full + // presets (already covered by combo), then bare suffixes. + want := []string{ + "archive", + "home", + "home@cloud", // home@ × @cloud + "@cloud", // bare suffix + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("presets mismatch:\n got: %#v\n want: %#v", got, want) + } +} + +func TestPresetsFromExplicitFullPreset(t *testing.T) { + t.Parallel() + + // When an explicit full preset exists that can't be generated from + // prefix×suffix, it should still appear. + const fixture = ` +[global] +verbose = true + +["@cloud"] +repo = "/repos/cloud" + +["special@remote"] +repo = "/repos/special-remote" +` + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(fixture), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + + got := presetsFrom([]string{path}) + + want := []string{ + "special@remote", // explicit full preset (no prefix@ exists) + "@cloud", // bare suffix + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("presets mismatch:\n got: %#v\n want: %#v", got, want) + } +} + +func TestPresetsFromMultiplePrefixesSuffixes(t *testing.T) { + t.Parallel() + + const fixture = ` +[global] +verbose = true + +["music@"] +tag = "music" + +["photos@"] +tag = "photos" + +["@hetzner"] +repo = "sftp:hetzner" + +["@b2"] +repo = "s3:b2" +` + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(fixture), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + + got := presetsFrom([]string{path}) + + // Combos: each prefix × each suffix (prefixes sorted, suffixes sorted). + want := []string{ + "music@b2", + "music@hetzner", + "photos@b2", + "photos@hetzner", + "@b2", + "@hetzner", + } if !reflect.DeepEqual(got, want) { t.Fatalf("presets mismatch:\n got: %#v\n want: %#v", got, want) @@ -81,3 +162,37 @@ func TestPresetsFromMissing(t *testing.T) { t.Fatalf("expected nil for missing file, got %#v", got) } } + +func TestPrefixesFrom(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(presetsFixtureTOML), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + + got := prefixesFrom([]string{path}) + want := []string{"home@"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("prefixes mismatch:\n got: %#v\n want: %#v", got, want) + } +} + +func TestSuffixesFrom(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(presetsFixtureTOML), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + + got := suffixesFrom([]string{path}) + want := []string{"@cloud"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("suffixes mismatch:\n got: %#v\n want: %#v", got, want) + } +} diff --git a/internal/restic/exec.go b/internal/restic/exec.go index 0ad31617ec395ac2eab6e288988f8bfca3d2265d..2689c2315dc1041ad282b180455a981ea82b4022 100644 --- a/internal/restic/exec.go +++ b/internal/restic/exec.go @@ -55,7 +55,7 @@ func DryRun(cfg *config.ResolvedConfig) string { } argv := buildArgv(executable(), cfg) - fmt.Fprintf(&b, "command: %s\n", strings.Join(argv, " ")) + fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv)) return b.String() } @@ -95,3 +95,19 @@ func buildEnv(extra map[string]string) []string { } return env } + +// quotedJoin formats an argv slice by wrapping every element in double +// quotes. This makes the dry-run output unambiguous — each argument +// boundary is visible even when values contain spaces. +func quotedJoin(argv []string) string { + var b strings.Builder + for i, arg := range argv { + if i > 0 { + b.WriteByte(' ') + } + b.WriteByte('"') + b.WriteString(arg) + b.WriteByte('"') + } + return b.String() +} diff --git a/internal/restic/exec_test.go b/internal/restic/exec_test.go index 08cb54effffc45d31767936f64afef0b5235fb10..b9007cdb5d0ad924156d87df4b90bd7e66a3b1e9 100644 --- a/internal/restic/exec_test.go +++ b/internal/restic/exec_test.go @@ -95,7 +95,7 @@ func TestDryRunOutput(t *testing.T) { "environ:", "RESTIC_REPOSITORY=/repos/home", "RESTIC_PASSWORD=secret", - "command: restic backup --repo /repos/home --json /home/alice", + "command: \"restic\" \"backup\" \"--repo\" \"/repos/home\" \"--json\" \"/home/alice\"", } { if !strings.Contains(output, fragment) { t.Fatalf("DryRun() missing fragment %q in output:\n%s", fragment, output) @@ -116,7 +116,7 @@ func TestDryRunExecutableOverride(t *testing.T) { cfg := &config.ResolvedConfig{Command: "snapshots"} output := DryRun(cfg) - expectedPrefix := "command: " + filepath.Join(homeDir, "bin", "restic-alt") + " snapshots" + expectedPrefix := `command: "` + filepath.Join(homeDir, "bin", "restic-alt") + `" "snapshots"` if !strings.Contains(output, expectedPrefix) { t.Fatalf("DryRun() command mismatch: want fragment %q in output %q", expectedPrefix, output) }