From 0c6feff659e5f9060c739a23b403813d963e8602 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 15 Mar 2026 12:17:47 -0600 Subject: [PATCH] Restructure CLI to use cobra subcommands with generated restic metadata --- cmd/completions.go | 223 +------------- cmd/completions_test.go | 340 +++++---------------- cmd/root.go | 266 ++++++---------- cmd/root_test.go | 259 ++++++++-------- cmd/subcommands.go | 136 +++++++++ examples/README.md | 2 +- examples/keld/config_long.toml | 2 +- examples/systemd/user/keld-backup@.service | 2 +- examples/systemd/user/keld-verify@.service | 2 +- 9 files changed, 459 insertions(+), 773 deletions(-) create mode 100644 cmd/subcommands.go diff --git a/cmd/completions.go b/cmd/completions.go index 0a89896cc486c5ad7c79fe8bcb98d45e718548ad..dde96b07a4c39f831188d700a910ad4d9e85ee01 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -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 -} diff --git a/cmd/completions_test.go b/cmd/completions_test.go index 224652b7147542e58bf87f57feac63a489b51d84..4bfc38ecfb3e10ed3b6aac6ad49042697c5c02b5 100644 --- a/cmd/completions_test.go +++ b/cmd/completions_test.go @@ -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 - // 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 } diff --git a/cmd/root.go b/cmd/root.go index ab12788656e04d2e3fb04294abcfef2f977a6338..b3dd8b8751227270ceff034eda526be8f01248e7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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] [restic flags...]", + Use: "keld [keld flags] [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 != "--" } diff --git a/cmd/root_test.go b/cmd/root_test.go index 05bfe51584c8fde74701b6a5a335f12860c4c728..36e14133a76b79766108492485319c9b1cb3890d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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) { diff --git a/cmd/subcommands.go b/cmd/subcommands.go new file mode 100644 index 0000000000000000000000000000000000000000..d189e85ce7da49261784d0d89c36b27424a5b5d9 --- /dev/null +++ b/cmd/subcommands.go @@ -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 +} diff --git a/examples/README.md b/examples/README.md index f4af1633ebe09806315349cef8d9e3df6d487b3f..ec17a54f1047caf555ba68e000107d1a5289388c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. diff --git a/examples/keld/config_long.toml b/examples/keld/config_long.toml index 49045635f0864c335e96dc7638a87ec0f03ecf29..40f901eae6ea40e8b6cc1d0eb3d2cc80caa19521 100644 --- a/examples/keld/config_long.toml +++ b/examples/keld/config_long.toml @@ -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"] diff --git a/examples/systemd/user/keld-backup@.service b/examples/systemd/user/keld-backup@.service index 69f8e214e1cdc8e60a884ad91cb0ec0e313a709c..9aad3b7cd99e53eb722f614261cad66b4ef12cfa 100644 --- a/examples/systemd/user/keld-backup@.service +++ b/examples/systemd/user/keld-backup@.service @@ -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 diff --git a/examples/systemd/user/keld-verify@.service b/examples/systemd/user/keld-verify@.service index 9e1aa59f8662d485a5838bbec159766f58e38655..42b74faf2423c3d9222d6294434aacdff529f733 100644 --- a/examples/systemd/user/keld-verify@.service +++ b/examples/systemd/user/keld-verify@.service @@ -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