Generate usable preset names from config fragments

Amolith created

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@<tab>.

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.

Change summary

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(-)

Detailed changes

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
 }

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
+}

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) {

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)
+	}
+}

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
 	}

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
 }

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)
+	}
+}

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()
+}

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)
 	}