Detailed changes
@@ -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
}
@@ -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
+}
@@ -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) {
@@ -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)
+ }
+}
@@ -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
}
@@ -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
}
@@ -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)
+ }
+}
@@ -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()
+}
@@ -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)
}