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