Detailed changes
@@ -1,116 +1,44 @@
package cmd
import (
- "slices"
"strings"
"github.com/spf13/cobra"
"git.secluded.site/keld/internal/config"
- "git.secluded.site/keld/internal/restic"
)
-// keldFlags lists keld's own flags (the ones extracted in extractOwnFlags).
-// Cobra can't advertise these automatically because DisableFlagParsing is on.
-var keldFlags = []string{
- "--config",
- "--dry-run",
- "--help",
-}
-
-func init() {
- rootCmd.ValidArgsFunction = completeArgs
-}
-
-// completeArgs provides dynamic shell completions for keld.
-//
-// It examines how many positional (non-flag) arguments have already been
-// provided and offers:
-//
-// - 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 "-", keld's own flags are offered instead.
-func completeArgs(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- // When the user is typing a flag, offer restic flags for the
- // identified command (plus global flags), or keld's own flags if
- // no command is known yet.
- if len(toComplete) > 0 && toComplete[0] == '-' {
- if cmd := commandFromArgs(args); cmd != "" {
- return completeResticFlags(cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
- }
- return keldFlags, cobra.ShellCompDirectiveNoFileComp
- }
-
- // Count how many positional args have already been accepted (i.e. are
- // in args, not toComplete). We need to skip flag tokens and their
- // values, just like parseArgs does.
- positionals := countPositionals(args)
-
- 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:
- // One positional committed. If it's a known command, there's
- // nothing more to complete positionally (the rest is restic
- // flags, which we don't complete).
- if isKnownCommand(args) {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
- // Otherwise the first positional was a preset; offer commands.
- return knownCommands, cobra.ShellCompDirectiveNoFileComp
- default:
- return nil, cobra.ShellCompDirectiveNoFileComp
+// completePreset offers completions for the root --preset flag.
+func completePreset(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ if strings.Contains(toComplete, "@") {
+ return completeSuffix(toComplete), cobra.ShellCompDirectiveNoFileComp
}
-}
-// presetsAndCommands merges config presets with the known command list,
-// 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(prefixes)+len(presets)+len(knownCommands))
- out := make([]string, 0, len(prefixes)+len(presets)+len(knownCommands))
+ seen := make(map[string]struct{}, len(prefixes)+len(presets))
+ out := make([]string, 0, len(prefixes)+len(presets))
add := func(s string) {
- if _, ok := seen[s]; !ok {
- seen[s] = struct{}{}
- out = append(out, s)
+ if !strings.HasPrefix(s, toComplete) {
+ return
+ }
+ if _, ok := seen[s]; ok {
+ return
}
+ seen[s] = struct{}{}
+ out = append(out, s)
}
- // 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 {
- add(c)
- }
-
- return out
+ return out, cobra.ShellCompDirectiveNoFileComp
}
// completeSuffix generates completions when the user has typed a prefix
@@ -135,126 +63,3 @@ func completeSuffix(typed string) []string {
}
return out
}
-
-// countPositionals returns the number of positional (non-flag) tokens in args.
-func countPositionals(args []string) int {
- n := 0
- for i := 0; i < len(args); i++ {
- arg := args[i]
- switch {
- case arg == "--":
- // Everything after -- is positional.
- n += len(args) - i - 1
- return n
- case isFlagToken(arg):
- // Skip the flag's value if it has one.
- if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
- i++
- }
- default:
- n++
- }
- }
- return n
-}
-
-// isKnownCommand reports whether the first positional arg in the already-
-// accepted args is a known restic command.
-func isKnownCommand(args []string) bool {
- for i := 0; i < len(args); i++ {
- arg := args[i]
- switch {
- case arg == "--":
- return false
- case isFlagToken(arg):
- if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
- i++
- }
- default:
- // First positional — check if it's a command.
- return slices.Contains(knownCommands, arg)
- }
- }
- return false
-}
-
-func hasEqualSign(arg string) bool {
- for _, c := range arg {
- if c == '=' {
- return true
- }
- }
- return false
-}
-
-// commandFromArgs extracts the restic command name from the already-accepted
-// args. It finds the first or second positional token (depending on whether
-// the first is a preset or a command) and returns it if it exists in
-// restic.Commands.
-func commandFromArgs(args []string) string {
- var positionals []string
-loop:
- for i := 0; i < len(args); i++ {
- arg := args[i]
- switch {
- case arg == "--":
- return ""
- case isFlagToken(arg):
- if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" {
- i++
- }
- default:
- positionals = append(positionals, arg)
- if len(positionals) == 2 {
- break loop
- }
- }
- }
-
- // With 1 positional, it could be a command.
- // With 2 positionals, the second is the command.
- for i := len(positionals) - 1; i >= 0; i-- {
- if _, ok := restic.Commands[positionals[i]]; ok && positionals[i] != "global" {
- return positionals[i]
- }
- }
- return ""
-}
-
-// completeResticFlags returns flag completions for the given restic command,
-// including global flags. Flags are prefixed with "--" (long) and "-" (short
-// aliases). Results are filtered by the toComplete prefix.
-func completeResticFlags(command, toComplete string) []string {
- seen := make(map[string]struct{})
- var out []string
-
- addOptions := func(opts []restic.Option) {
- for _, opt := range opts {
- long := "--" + opt.Name
- if _, ok := seen[long]; !ok {
- seen[long] = struct{}{}
- if strings.HasPrefix(long, toComplete) {
- out = append(out, long)
- }
- }
- if opt.Alias != "" {
- short := "-" + opt.Alias
- if _, ok := seen[short]; !ok {
- seen[short] = struct{}{}
- if strings.HasPrefix(short, toComplete) {
- out = append(out, short)
- }
- }
- }
- }
- }
-
- if cmd, ok := restic.Commands[command]; ok {
- addOptions(cmd.Options)
- }
- if global, ok := restic.Commands["global"]; ok {
- addOptions(global.Options)
- }
-
- return out
-}
@@ -8,6 +8,8 @@ import (
"testing"
"github.com/spf13/cobra"
+
+ "git.secluded.site/keld/internal/restic"
)
// setupCompletionConfig writes a small TOML fixture and points KELD_CONFIG_FILE
@@ -40,16 +42,14 @@ json = true
t.Setenv("HOME", dir)
}
-func TestCompleteArgsNoArgs(t *testing.T) {
+func TestCompletePresetOffersPrefixesAndPresets(t *testing.T) {
setupCompletionConfig(t)
- completions, directive := completeArgs(nil, nil, "")
+ completions, directive := completePreset(nil, nil, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
- t.Fatalf("expected NoFileComp directive, got %v", directive)
+ t.Fatalf("expected NoFileComp, got %v", directive)
}
- // Ordering: prefixes first, then plain presets, composite presets,
- // bare suffixes, then commands.
has := make(map[string]bool)
for _, c := range completions {
has[c] = true
@@ -58,325 +58,145 @@ func TestCompleteArgsNoArgs(t *testing.T) {
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
+ "home@cloud", // composite preset
+ "home@nas", // composite preset
+ "@cloud", // suffix preset
+ "@nas", // suffix preset
} {
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) {
+func TestCompletePresetSuffixCompletion(t *testing.T) {
setupCompletionConfig(t)
- completions, directive := completeArgs(nil, []string{"home@cloud"}, "")
+ completions, directive := completePreset(nil, nil, "home@")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("expected NoFileComp, got %v", directive)
}
+
sort.Strings(completions)
- want := make([]string, len(knownCommands))
- copy(want, knownCommands)
- sort.Strings(want)
+ want := []string{"home@cloud", "home@nas"}
if !reflect.DeepEqual(completions, want) {
- t.Fatalf("after preset, expected commands %v, got %v", want, completions)
- }
-}
-
-func TestCompleteArgsAfterCommand(t *testing.T) {
- setupCompletionConfig(t)
-
- completions, directive := completeArgs(nil, []string{"backup"}, "")
- if directive != cobra.ShellCompDirectiveNoFileComp {
- t.Fatalf("expected NoFileComp, got %v", directive)
- }
- if len(completions) != 0 {
- t.Fatalf("after command, expected no completions, got %v", completions)
+ t.Fatalf("suffix completions mismatch: got %v, want %v", completions, want)
}
}
-func TestCompleteArgsAfterPresetAndCommand(t *testing.T) {
+func TestCompletePresetPartialSuffixCompletion(t *testing.T) {
setupCompletionConfig(t)
- completions, directive := completeArgs(nil, []string{"home@cloud", "backup"}, "")
+ completions, directive := completePreset(nil, nil, "home@c")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("expected NoFileComp, got %v", directive)
}
- if len(completions) != 0 {
- t.Fatalf("after preset+command, expected no completions, got %v", completions)
- }
-}
-
-func TestCompleteArgsFlagPrefix(t *testing.T) {
- t.Parallel()
- // No config needed — flag completions are static.
- completions, directive := completeArgs(nil, nil, "--")
- if directive != cobra.ShellCompDirectiveNoFileComp {
- t.Fatalf("expected NoFileComp, got %v", directive)
- }
- sort.Strings(completions)
- want := []string{"--config", "--dry-run", "--help"}
+ want := []string{"home@cloud"}
if !reflect.DeepEqual(completions, want) {
- t.Fatalf("flag completions mismatch: got %v, want %v", completions, want)
+ t.Fatalf("partial suffix completions mismatch: got %v, want %v", completions, want)
}
}
-func TestCompleteArgsSkipsFlags(t *testing.T) {
+func TestCompletePresetPrefixFilter(t *testing.T) {
setupCompletionConfig(t)
- // Simulate: keld --config ./keld.toml <tab>
- // The flag and its value should not count as positionals, so we
- // should still be at 0 positionals → presets + commands.
- completions, directive := completeArgs(nil, []string{"--config", "./keld.toml"}, "")
+ completions, directive := completePreset(nil, nil, "arc")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("expected NoFileComp, got %v", directive)
}
- has := make(map[string]bool)
- for _, c := range completions {
- has[c] = true
- }
- if !has["backup"] {
- t.Errorf("expected commands after flag+value, got %v", completions)
- }
- if !has["home@"] {
- t.Errorf("expected prefix 'home@' after flag+value, got %v", completions)
+ want := []string{"archive"}
+ if !reflect.DeepEqual(completions, want) {
+ t.Fatalf("prefix-filtered completions mismatch: got %v, want %v", completions, want)
}
}
-func TestCompleteArgsSuffixCompletion(t *testing.T) {
- setupCompletionConfig(t)
+func TestSubcommandsRegisteredFromResticMetadata(t *testing.T) {
+ t.Parallel()
- // 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)
+ subcommands := rootCmd.Commands()
+ if got, want := len(subcommands), len(restic.Commands)-1; got != want {
+ t.Fatalf("subcommand count mismatch: got %d, want %d", got, want)
}
- 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)
+ seen := make(map[string]*cobra.Command, len(subcommands))
+ for _, subcmd := range subcommands {
+ seen[subcmd.Name()] = subcmd
}
-}
-
-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)
+ if _, ok := seen["global"]; ok {
+ t.Fatal("global command should not be registered as a subcommand")
}
- want := []string{"home@cloud"}
- if !reflect.DeepEqual(completions, want) {
- t.Fatalf("partial suffix completions mismatch: got %v, want %v", completions, want)
- }
-}
-
-func TestCountPositionals(t *testing.T) {
- t.Parallel()
+ for name := range restic.Commands {
+ name := name
+ if name == "global" {
+ continue
+ }
- tests := []struct {
- name string
- args []string
- want int
- }{
- {name: "empty", args: nil, want: 0},
- {name: "one positional", args: []string{"backup"}, want: 1},
- {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@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) {
- t.Parallel()
- got := countPositionals(tt.args)
- if got != tt.want {
- t.Fatalf("countPositionals(%#v) = %d, want %d", tt.args, got, tt.want)
- }
- })
+ subcmd, ok := seen[name]
+ if !ok {
+ t.Fatalf("missing subcommand %q", name)
+ }
+ if !subcmd.DisableFlagParsing {
+ t.Fatalf("subcommand %q should have DisableFlagParsing enabled", name)
+ }
}
}
-func TestIsKnownCommand(t *testing.T) {
+func TestRegisteredRootAndSubcommandFlags(t *testing.T) {
t.Parallel()
- tests := []struct {
- name string
- args []string
- want bool
- }{
- {name: "empty", args: nil, want: false},
- {name: "command first", args: []string{"backup"}, want: true},
- {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@cloud"}, want: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := isKnownCommand(tt.args)
- if got != tt.want {
- t.Fatalf("isKnownCommand(%#v) = %v, want %v", tt.args, got, tt.want)
- }
- })
+ for _, flagName := range []string{"preset", "show-command", "config", "repo", "verbose"} {
+ if rootCmd.PersistentFlags().Lookup(flagName) == nil {
+ t.Fatalf("missing expected root persistent flag %q", flagName)
+ }
}
-}
-
-func TestCompleteArgsResticFlagsAfterCommand(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- args []string
- toComplete string
- wantFlags []string // flags that must appear in completions
- wantAbsent []string // flags that must not appear
- }{
- {
- name: "backup flags with -- prefix",
- args: []string{"backup"},
- toComplete: "--",
- wantFlags: []string{"--dry-run", "--exclude", "--tag"},
- wantAbsent: []string{"--target"}, // restore-only flag
- },
- {
- name: "backup short aliases",
- args: []string{"backup"},
- toComplete: "-",
- wantFlags: []string{"-n", "-e", "--dry-run", "--repo"},
- },
- {
- name: "restore flags include target",
- args: []string{"restore"},
- toComplete: "--t",
- wantFlags: []string{"--target", "--tag", "--tls-client-cert"},
- },
- {
- name: "global flags included with command",
- args: []string{"backup"},
- toComplete: "--re",
- wantFlags: []string{"--repo", "--repository-file", "--retry-lock"},
- },
- {
- name: "preset then command",
- args: []string{"home@cloud", "backup"},
- toComplete: "--dr",
- wantFlags: []string{"--dry-run"},
- },
- {
- name: "flag then command",
- args: []string{"--config", "./keld.toml", "backup"},
- toComplete: "--",
- wantFlags: []string{"--dry-run", "--exclude"},
- },
+ if rootCmd.PersistentFlags().Lookup("dry-run") != nil {
+ t.Fatal("root should not expose a --dry-run flag")
}
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
-
- completions, directive := completeArgs(nil, tt.args, tt.toComplete)
- if directive != cobra.ShellCompDirectiveNoFileComp {
- t.Fatalf("expected NoFileComp, got %v", directive)
- }
-
- has := make(map[string]bool, len(completions))
- for _, c := range completions {
- has[c] = true
- }
-
- for _, want := range tt.wantFlags {
- if !has[want] {
- t.Errorf("missing expected flag %q in completions %v", want, completions)
- }
- }
- for _, absent := range tt.wantAbsent {
- if has[absent] {
- t.Errorf("unexpected flag %q in completions %v", absent, completions)
- }
- }
- })
+ if got := rootCmd.PersistentFlags().Lookup("verbose").Value.Type(); got != "count" {
+ t.Fatalf("expected --verbose to be a count flag, got type %q", got)
}
-}
-func TestCompleteArgsNoCommandStillReturnsKeldFlags(t *testing.T) {
- t.Parallel()
+ backup := rootCmd.Commands()[0]
+ for _, subcmd := range rootCmd.Commands() {
+ if subcmd.Name() == "backup" {
+ backup = subcmd
+ break
+ }
+ }
- completions, directive := completeArgs(nil, nil, "--")
- if directive != cobra.ShellCompDirectiveNoFileComp {
- t.Fatalf("expected NoFileComp, got %v", directive)
+ for _, flagName := range []string{"exclude", "dry-run"} {
+ if backup.Flags().Lookup(flagName) == nil {
+ t.Fatalf("backup subcommand missing expected command flag %q", flagName)
+ }
}
- sort.Strings(completions)
- want := []string{"--config", "--dry-run", "--help"}
- if !reflect.DeepEqual(completions, want) {
- t.Fatalf("without command, flag completions mismatch: got %v, want %v", completions, want)
+
+ if backup.InheritedFlags().Lookup("repo") == nil {
+ t.Fatal("backup subcommand missing inherited global --repo flag")
}
}
-func TestCommandFromArgs(t *testing.T) {
- t.Parallel()
+func TestPresetFlagCompletionRegistered(t *testing.T) {
+ setupCompletionConfig(t)
- tests := []struct {
- name string
- args []string
- want string
- }{
- {name: "empty", args: nil, want: ""},
- {name: "command only", args: []string{"backup"}, want: "backup"},
- {name: "preset then command", args: []string{"home@cloud", "backup"}, want: "backup"},
- {name: "unknown preset alone", args: []string{"home@cloud"}, want: ""},
- {name: "flag then command", args: []string{"--config", "./f", "backup"}, want: "backup"},
- {name: "double dash", args: []string{"backup", "--"}, want: ""},
+ completionFn, ok := rootCmd.GetFlagCompletionFunc("preset")
+ if !ok {
+ t.Fatal("root --preset completion function is not registered")
}
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
-
- got := commandFromArgs(tt.args)
- if got != tt.want {
- t.Fatalf("commandFromArgs(%#v) = %q, want %q", tt.args, got, tt.want)
- }
- })
+ completions, directive := completionFn(rootCmd, nil, "home@")
+ if directive != cobra.ShellCompDirectiveNoFileComp {
+ t.Fatalf("expected NoFileComp, got %v", directive)
}
-}
-// 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
- }
+ sort.Strings(completions)
+ want := []string{"home@cloud", "home@nas"}
+ if !reflect.DeepEqual(completions, want) {
+ t.Fatalf("completion function returned %v, want %v", completions, want)
}
- return -1
}
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
+ "slices"
"strings"
tea "charm.land/bubbletea/v2"
@@ -17,200 +18,132 @@ import (
)
var (
- flagDryRun bool
+ flagPreset string
+ flagShowCmd bool
flagConfigFile string
)
const overrideArgumentsKey = "_arguments"
var rootCmd = &cobra.Command{
- Use: "keld [preset] <command> [restic flags...]",
+ Use: "keld [keld flags] <command> [restic flags...]",
Short: "A friendly wrapper around restic",
Long: "keld resolves layered TOML config presets and executes restic with the merged result.",
Example: ` keld backup
- keld home backup
- keld home@nas backup --tag daily --verbose
- keld --config ./keld.toml home backup
- keld --dry-run home backup
- keld --help`,
-
- // Accept arbitrary args — we parse preset/command ourselves.
- Args: cobra.ArbitraryArgs,
- DisableFlagParsing: true,
- SilenceUsage: true,
- SilenceErrors: true,
-
- RunE: func(cmd *cobra.Command, args []string) error {
- // Re-parse our own flags out of the raw args since cobra flag
- // parsing is disabled (we need to pass unknown flags through to
- // restic).
- args = extractOwnFlags(args)
-
- if hasHelpFlag(args) {
- return cmd.Help()
+ keld --preset home backup
+ keld --preset home@nas backup --tag daily /src
+ keld --show-command --preset home backup
+ keld --config ./keld.toml --preset home backup
+ keld backup --help
+ keld`,
+
+ SilenceUsage: true,
+ SilenceErrors: true,
+ TraverseChildren: true,
+
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ selected, err := runMenu()
+ if err != nil {
+ return fmt.Errorf("menu: %w", err)
}
-
- if flagConfigFile != "" {
- if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
- return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
- }
+ if selected == "" || selected == "quit" {
+ return nil
}
- if flagDryRun {
- if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
- return fmt.Errorf("setting KELD_DRYRUN: %w", err)
- }
+ if !slices.Contains(knownCommands, selected) {
+ return fmt.Errorf("unknown menu command %q", selected)
}
- preset, command, passthrough := parseArgs(args)
+ return runCommand(selected, cmd, nil)
+ },
+}
- // No command → interactive menu.
- interactive := command == ""
- if interactive {
- selected, err := runMenu()
- if err != nil {
- return fmt.Errorf("menu: %w", err)
- }
- if selected == "" || selected == "quit" {
- return nil
- }
- command = selected
+func registerRootFlags() {
+ flags := rootCmd.PersistentFlags()
+ flags.StringVarP(&flagPreset, "preset", "P", "", "config preset to apply before running the command")
+ flags.BoolVar(&flagShowCmd, "show-command", false, "print the resolved restic command and exit")
+ flags.StringVarP(&flagConfigFile, "config", "C", "", "path to keld config file")
+}
+
+func runCommand(commandName string, cmd *cobra.Command, rawArgs []string) error {
+ if flagConfigFile != "" {
+ if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
+ return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
}
+ }
+ if flagShowCmd {
+ if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
+ return fmt.Errorf("setting KELD_DRYRUN: %w", err)
+ }
+ }
- // 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
- }
+ preset := flagPreset
+ interactive := cmd == cmd.Root()
+
+ if preset != "" {
+ if err := validatePreset(preset); err != nil {
+ return err
}
+ }
- overrides := parsePassthrough(passthrough)
+ overrides := parsePassthrough(rawArgs)
- // When launched interactively, prompt for a preset and any
- // command-specific inputs that restic would otherwise reject.
- if interactive {
+ // In interactive mode, fill missing preset/command inputs via prompts.
+ if interactive {
+ if preset == "" {
p, err := promptPreset()
if err != nil {
return err
}
preset = p
-
- // Resolve config before prompting so we can see what the
- // preset already provides and skip unnecessary prompts.
- peek, err := config.Resolve(preset, command, overrides)
- if err != nil {
- return err
- }
-
- cmdOverrides, err := promptForCommand(command, peek)
- if err != nil {
- return err
- }
- if overrides == nil && len(cmdOverrides) > 0 {
- overrides = cmdOverrides
- } else {
- for k, v := range cmdOverrides {
- overrides[k] = v
- }
- }
}
- cfg, err := config.Resolve(preset, command, overrides)
+ // Resolve config before prompting so we can skip questions for values
+ // already provided by presets.
+ peek, err := config.Resolve(preset, commandName, overrides)
if err != nil {
return err
}
- restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
-
- if config.IsDryRun() {
- fmt.Print(restic.DryRun(cfg))
- return nil
+ cmdOverrides, err := promptForCommand(commandName, peek)
+ if err != nil {
+ return err
}
-
- return restic.Run(cfg)
- },
-}
-
-// Execute is the main entry point, called from main.go.
-func Execute() {
- if err := fang.Execute(context.Background(), rootCmd); err != nil {
- os.Exit(1)
+ overrides = mergeOverrides(overrides, cmdOverrides)
}
-}
-// extractOwnFlags pulls --dry-run and --config out of the raw arg list,
-// setting package-level flags, and returns the remaining args.
-func extractOwnFlags(args []string) []string {
- var rest []string
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--dry-run":
- flagDryRun = true
- case "--config":
- if i+1 < len(args) {
- i++
- flagConfigFile = args[i]
- }
- case "--help", "-h":
- // Keep help flags in the raw list so RunE can dispatch help.
- rest = append(rest, args[i])
- default:
- rest = append(rest, args[i])
- }
+ cfg, err := config.Resolve(preset, commandName, overrides)
+ if err != nil {
+ return err
}
- return rest
-}
-func hasHelpFlag(args []string) bool {
- for _, arg := range args {
- if arg == "--help" || arg == "-h" {
- return true
- }
+ restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
+
+ if config.IsDryRun() {
+ fmt.Print(restic.DryRun(cfg))
+ return nil
}
- return false
+
+ return restic.Run(cfg)
}
-// parseArgs splits args into (preset, command, remaining) using crestic-style
-// positional arity.
-//
-// Rules:
-// - 0 positional args: no preset, no command.
-// - 1 positional arg: command only.
-// - 2+ positional args: first is preset, second is command.
-//
-// All unconsumed args are returned as passthrough for parsePassthrough.
-func parseArgs(args []string) (preset, command string, rest []string) {
- positionals := positionalIndices(args)
- if len(positionals) == 0 {
- if len(args) == 0 {
- return "", "", nil
- }
- return "", "", append([]string(nil), args...)
+func mergeOverrides(base, extra map[string][]string) map[string][]string {
+ if len(extra) == 0 {
+ return base
}
-
- consumed := make(map[int]struct{}, 2)
- if len(positionals) == 1 {
- idx := positionals[0]
- command = args[idx]
- consumed[idx] = struct{}{}
- } else {
- presetIdx := positionals[0]
- commandIdx := positionals[1]
- preset = args[presetIdx]
- command = args[commandIdx]
- consumed[presetIdx] = struct{}{}
- consumed[commandIdx] = struct{}{}
+ if base == nil {
+ base = make(map[string][]string, len(extra))
}
-
- rest = make([]string, 0, len(args)-len(consumed))
- for i, arg := range args {
- if _, ok := consumed[i]; ok {
- continue
- }
- rest = append(rest, arg)
+ for key, values := range extra {
+ base[key] = values
}
+ return base
+}
- return preset, command, rest
+// Execute is the main entry point, called from main.go.
+func Execute() {
+ if err := fang.Execute(context.Background(), rootCmd); err != nil {
+ os.Exit(1)
+ }
}
// parsePassthrough converts restic-style CLI flags into a map suitable for
@@ -268,35 +201,6 @@ func parsePassthrough(args []string) map[string][]string {
return overrides
}
-// positionalIndices returns indices of positional arguments in args. Flag
-// values are treated as part of their flag, not positional args.
-func positionalIndices(args []string) []int {
- var idx []int
-
- for i := 0; i < len(args); i++ {
- arg := args[i]
-
- switch {
- case arg == "--":
- for j := i + 1; j < len(args); j++ {
- idx = append(idx, j)
- }
- return idx
- case isFlagToken(arg):
- if strings.Contains(arg, "=") {
- continue
- }
- if i+1 < len(args) && args[i+1] != "--" && !isFlagToken(args[i+1]) {
- i++
- }
- default:
- idx = append(idx, i)
- }
- }
-
- return idx
-}
-
func isFlagToken(arg string) bool {
return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
}
@@ -1,106 +1,16 @@
package cmd
import (
+ "bytes"
+ "io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
-)
-
-func TestParseArgs(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- args []string
- wantPreset string
- wantCommand string
- wantRest []string
- }{
- {
- name: "no args",
- args: nil,
- wantPreset: "",
- wantCommand: "",
- wantRest: nil,
- },
- {
- name: "single positional is command",
- args: []string{"backup"},
- wantPreset: "",
- wantCommand: "backup",
- wantRest: nil,
- },
- {
- name: "single split-like token is still command",
- args: []string{"home@nas"},
- wantPreset: "",
- wantCommand: "home@nas",
- wantRest: nil,
- },
- {
- name: "two positionals are preset and command",
- args: []string{"home", "backup"},
- wantPreset: "home",
- wantCommand: "backup",
- wantRest: nil,
- },
- {
- name: "two positionals plus passthrough",
- args: []string{"home@nas", "backup", "--tag", "daily", "/src"},
- wantPreset: "home@nas",
- wantCommand: "backup",
- wantRest: []string{"--tag", "daily", "/src"},
- },
- {
- name: "single command with flags",
- args: []string{"backup", "--repo", "/repo", "--json"},
- wantPreset: "",
- wantCommand: "backup",
- wantRest: []string{"--repo", "/repo", "--json"},
- },
- {
- name: "flags before command",
- args: []string{"--repo", "/repo", "backup", "--json"},
- wantPreset: "",
- wantCommand: "backup",
- wantRest: []string{"--repo", "/repo", "--json"},
- },
- {
- name: "no positional command when only flags provided",
- args: []string{"--repo", "/repo", "--json"},
- wantPreset: "",
- wantCommand: "",
- wantRest: []string{"--repo", "/repo", "--json"},
- },
- {
- name: "double-dash keeps following literals in passthrough",
- args: []string{"home", "backup", "--", "--literal", "path"},
- wantPreset: "home",
- wantCommand: "backup",
- wantRest: []string{"--", "--literal", "path"},
- },
- }
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
-
- gotPreset, gotCommand, gotRest := parseArgs(tt.args)
- if gotPreset != tt.wantPreset {
- t.Fatalf("preset mismatch: got %q, want %q", gotPreset, tt.wantPreset)
- }
- if gotCommand != tt.wantCommand {
- t.Fatalf("command mismatch: got %q, want %q", gotCommand, tt.wantCommand)
- }
- if !equalStringSlices(gotRest, tt.wantRest) {
- t.Fatalf("rest mismatch: got %#v, want %#v", gotRest, tt.wantRest)
- }
- })
- }
-}
+ "github.com/spf13/cobra"
+)
func TestParsePassthrough(t *testing.T) {
t.Parallel()
@@ -190,39 +100,150 @@ func TestParsePassthrough(t *testing.T) {
}
}
-func TestHasHelpFlag(t *testing.T) {
- t.Parallel()
+func TestRunCommandAppliesRootFlagsAndPassthrough(t *testing.T) {
+ configFile := setupCommandConfig(t)
+ setRootFlagValuesForTest(t, "home@cloud", true, configFile)
+ t.Setenv("KELD_CONFIG_FILE", "")
+ t.Setenv("KELD_DRYRUN", "")
- tests := []struct {
- name string
- args []string
- want bool
- }{
- {name: "no args", args: nil, want: false},
- {name: "no help token", args: []string{"backup", "--repo", "/repo"}, want: false},
- {name: "short help", args: []string{"-h"}, want: true},
- {name: "long help", args: []string{"--help"}, want: true},
- {name: "help mixed with args", args: []string{"home", "backup", "--help"}, want: true},
+ backup := lookupSubcommand(t, "backup")
+ out, err := captureStdout(t, func() error {
+ return runCommand("backup", backup, []string{"--tag", "daily", "/src"})
+ })
+ if err != nil {
+ t.Fatalf("runCommand returned error: %v", err)
}
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
+ if got := os.Getenv("KELD_CONFIG_FILE"); got != configFile {
+ t.Fatalf("KELD_CONFIG_FILE mismatch: got %q, want %q", got, configFile)
+ }
+ if got := os.Getenv("KELD_DRYRUN"); got != "1" {
+ t.Fatalf("KELD_DRYRUN mismatch: got %q, want %q", got, "1")
+ }
- got := hasHelpFlag(tt.args)
- if got != tt.want {
- t.Fatalf("hasHelpFlag(%#v) = %v, want %v", tt.args, got, tt.want)
- }
- })
+ for _, want := range []string{"\"backup\"", "\"--tag\" \"daily\"", "\"/src\""} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("dry-run output missing %q:\n%s", want, out)
+ }
+ }
+}
+
+func TestRunCommandRejectsUnknownPreset(t *testing.T) {
+ configFile := setupCommandConfig(t)
+ setRootFlagValuesForTest(t, "missing", true, configFile)
+
+ backup := lookupSubcommand(t, "backup")
+ err := runCommand("backup", backup, nil)
+ if err == nil {
+ t.Fatal("expected unknown preset error")
+ }
+ if !strings.Contains(err.Error(), "unknown preset") {
+ t.Fatalf("expected unknown preset error, got: %v", err)
+ }
+}
+
+func TestSubcommandHelpShowsCobraHelp(t *testing.T) {
+ backup := lookupSubcommand(t, "backup")
+ buf := &bytes.Buffer{}
+ backup.SetOut(buf)
+ backup.SetErr(buf)
+ t.Cleanup(func() {
+ backup.SetOut(os.Stdout)
+ backup.SetErr(os.Stderr)
+ })
+
+ err := backup.RunE(backup, []string{"--help"})
+ if err != nil {
+ t.Fatalf("backup --help returned error: %v", err)
+ }
+
+ helpText := buf.String()
+ if !strings.Contains(helpText, "Usage:") {
+ t.Fatalf("help output missing Usage section:\n%s", helpText)
+ }
+ if !strings.Contains(helpText, "--exclude") {
+ t.Fatalf("help output missing backup flag listing:\n%s", helpText)
+ }
+ if !strings.Contains(helpText, "--repo") {
+ t.Fatalf("help output missing inherited global flags:\n%s", helpText)
+ }
+}
+
+func setupCommandConfig(t *testing.T) string {
+ t.Helper()
+
+ dir := t.TempDir()
+ cfg := filepath.Join(dir, "config.toml")
+ err := os.WriteFile(cfg, []byte(`
+[global]
+repo = "/repos/default"
+
+["home@"]
+tag = "home"
+
+["@cloud"]
+repo = "/repos/cloud"
+
+[archive]
+json = true
+`), 0o600)
+ if err != nil {
+ t.Fatalf("writing fixture: %v", err)
+ }
+ t.Setenv("HOME", dir)
+
+ return cfg
+}
+
+func setRootFlagValuesForTest(t *testing.T, preset string, showCommand bool, configFile string) {
+ t.Helper()
+
+ prevPreset, prevShowCommand, prevConfigFile := flagPreset, flagShowCmd, flagConfigFile
+ flagPreset = preset
+ flagShowCmd = showCommand
+ flagConfigFile = configFile
+ t.Cleanup(func() {
+ flagPreset = prevPreset
+ flagShowCmd = prevShowCommand
+ flagConfigFile = prevConfigFile
+ })
+}
+
+func lookupSubcommand(t *testing.T, name string) *cobra.Command {
+ t.Helper()
+
+ for _, cmd := range rootCmd.Commands() {
+ if cmd.Name() == name {
+ return cmd
+ }
}
+ t.Fatalf("subcommand %q not found", name)
+ return nil
}
-func equalStringSlices(a, b []string) bool {
- if len(a) == 0 && len(b) == 0 {
- return true
+func captureStdout(t *testing.T, run func() error) (string, error) {
+ t.Helper()
+
+ oldStdout := os.Stdout
+ reader, writer, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("creating stdout pipe: %v", err)
+ }
+ os.Stdout = writer
+ t.Cleanup(func() {
+ os.Stdout = oldStdout
+ })
+
+ runErr := run()
+ _ = writer.Close()
+
+ out, readErr := io.ReadAll(reader)
+ if readErr != nil {
+ t.Fatalf("reading stdout: %v", readErr)
}
- return reflect.DeepEqual(a, b)
+ _ = reader.Close()
+
+ return string(out), runErr
}
func TestValidatePreset(t *testing.T) {
@@ -0,0 +1,136 @@
+package cmd
+
+import (
+ "sort"
+ "strconv"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+
+ "git.secluded.site/keld/internal/restic"
+)
+
+func init() {
+ registerRootFlags()
+ registerGlobalFlags()
+ registerSubcommands()
+
+ if err := rootCmd.RegisterFlagCompletionFunc("preset", completePreset); err != nil {
+ panic(err)
+ }
+}
+
+func registerSubcommands() {
+ names := make([]string, 0, len(restic.Commands))
+ for name := range restic.Commands {
+ if name == "global" {
+ continue
+ }
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ for _, name := range names {
+ def := restic.Commands[name]
+ commandName := name
+
+ subCmd := &cobra.Command{
+ Use: commandName + " [flags...] [args...]",
+ Short: def.Description,
+ DisableFlagParsing: true,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if wantsCommandHelp(args) {
+ return cmd.Help()
+ }
+ return runCommand(commandName, cmd, args)
+ },
+ }
+
+ registerResticOptions(subCmd.Flags(), def.Options)
+ rootCmd.AddCommand(subCmd)
+ }
+}
+
+func registerGlobalFlags() {
+ global, ok := restic.Commands["global"]
+ if !ok {
+ panic("restic command metadata missing global command")
+ }
+
+ flags := rootCmd.PersistentFlags()
+ for _, opt := range global.Options {
+ switch opt.Name {
+ case "help":
+ continue
+ case "verbose":
+ registerCountOption(flags, opt)
+ continue
+ }
+
+ registerResticOption(flags, opt)
+ }
+}
+
+func registerResticOptions(flags *pflag.FlagSet, options []restic.Option) {
+ for _, opt := range options {
+ if opt.Name == "help" {
+ continue
+ }
+ registerResticOption(flags, opt)
+ }
+}
+
+func registerResticOption(flags *pflag.FlagSet, opt restic.Option) {
+ shorthand := sanitizeShorthand(opt.Alias)
+
+ switch {
+ case opt.IsBool:
+ flags.BoolP(opt.Name, shorthand, optionDefaultBool(opt.Default), opt.Description)
+ case opt.Repeatable:
+ flags.StringArrayP(opt.Name, shorthand, nil, opt.Description)
+ default:
+ flags.StringP(opt.Name, shorthand, opt.Default, opt.Description)
+ }
+}
+
+func registerCountOption(flags *pflag.FlagSet, opt restic.Option) {
+ shorthand := sanitizeShorthand(opt.Alias)
+ value := flags.CountP(opt.Name, shorthand, opt.Description)
+
+ level, err := strconv.Atoi(opt.Default)
+ if err != nil || level == 0 {
+ return
+ }
+
+ *value = level
+ _ = flags.Set(opt.Name, strconv.Itoa(level))
+}
+
+func sanitizeShorthand(alias string) string {
+ if len(alias) == 1 {
+ return alias
+ }
+ return ""
+}
+
+func optionDefaultBool(raw string) bool {
+ v, err := strconv.ParseBool(raw)
+ if err != nil {
+ return false
+ }
+ return v
+}
+
+func wantsCommandHelp(args []string) bool {
+ for _, arg := range args {
+ if arg == "--" {
+ return false
+ }
+ if arg == "--help" || arg == "-h" {
+ return true
+ }
+ }
+ return false
+}
@@ -73,7 +73,7 @@ systemctl --user list-timers
systemctl --user status keld-backup@media@hetzner_media.service
# Test a dry run
-keld --dry-run media@hetzner_media backup
+keld --show-command --preset media@hetzner_media backup
```
Repeat this to add backups later.
@@ -16,7 +16,7 @@ exclude-file = "~/.config/restic/excludes.txt"
exclude-if-present = ".nobackup"
[home.backup]
-# Simple preset: `keld home backup`
+# Simple preset: `keld --preset home backup`
_arguments = ["/home/amolith"]
tag = ["home"]
@@ -6,4 +6,4 @@ Nice=19
IOSchedulingClass=idle
KillSignal=SIGINT
EnvironmentFile=-%h/.config/keld/timers/%I.env
-ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld %I backup
+ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld --preset %I backup
@@ -6,4 +6,4 @@ Nice=19
IOSchedulingClass=idle
KillSignal=SIGINT
EnvironmentFile=-%h/.config/keld/timers/%I-verify.env
-ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld %I verify
+ExecStart=%h/.local/bin/mise x github:bdd/runitor -- runitor -- mise x http:keld -- keld --preset %I check