diff --git a/cmd/root.go b/cmd/root.go index 080e062728e2c54e4c53bbe792c30a2846b7f2da..099b8ad946d2e55f5e754b714dc3ca27135b87d5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -45,7 +45,7 @@ var rootCmd = &cobra.Command{ TraverseChildren: true, RunE: func(cmd *cobra.Command, _ []string) error { - commandName, preset, overrides, err := runInteractive() + commandName, preset, overrides, err := runInteractive("") if err != nil { return err } @@ -85,7 +85,11 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessio } preset := flagPreset - interactive := cmd == cmd.Root() + // Interactive mode when launched from root command or when a + // TUI session ran (presetResolved). The session handles preset + // selection, but runCommand may still need to collect values + // for commands without dedicated TUI screens (e.g. backup paths). + interactive := cmd == cmd.Root() || presetResolved if preset != "" { if err := validatePreset(preset); err != nil { @@ -219,13 +223,18 @@ func isFlagToken(arg string) bool { // menu, preset selector (if needed), and command-specific screens // (e.g. the restore flow). Returns the chosen command, preset, any // collected overrides, or ("", "", nil, nil) if the user cancelled. -func runInteractive() (command, preset string, overrides map[string][]string, err error) { +func runInteractive(preselectedCommand string) (command, preset string, overrides map[string][]string, err error) { styles := theme.New(true) var screenList []ui.Screen - menuScreen := screens.NewMenu(menuItems, &styles) - screenList = append(screenList, menuScreen) + // When a command is pre-selected (e.g. "keld restore"), skip the + // menu entirely. Otherwise show the interactive command menu. + var menuScreen *screens.Menu + if preselectedCommand == "" { + menuScreen = screens.NewMenu(menuItems, &styles) + screenList = append(screenList, menuScreen) + } // Build the preset screen only when needed: // - --preset on CLI: skip (already resolved). @@ -251,7 +260,10 @@ func runInteractive() (command, preset string, overrides map[string][]string, er // screens. It resolves the config and dynamically builds the // remaining screens via ExtendMsg. resolveScreen := screens.NewResolve(func() []ui.Screen { - cmd := menuScreen.Selection() + cmd := preselectedCommand + if menuScreen != nil { + cmd = menuScreen.Selection() + } p := preset if presetScreen != nil { p = presetScreen.Value() @@ -296,7 +308,10 @@ func runInteractive() (command, preset string, overrides map[string][]string, er return "", "", nil, nil } - command = menuScreen.Selection() + command = preselectedCommand + if menuScreen != nil { + command = menuScreen.Selection() + } if !slices.Contains(knownCommands, command) { return "", "", nil, fmt.Errorf("unknown menu command %q", command) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 30d5957c6c588d8eab27466d36833d3b674d7e12..8b46fb0ed1c175a6d03a0e9cf8b6c72f39cf307f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -199,13 +199,16 @@ func setRootFlagValuesForTest(t *testing.T, preset string, showCommand bool, con t.Helper() prevPreset, prevShowCommand, prevConfigFile := flagPreset, flagShowCmd, flagConfigFile + prevPresetResolved := presetResolved flagPreset = preset flagShowCmd = showCommand flagConfigFile = configFile + presetResolved = false t.Cleanup(func() { flagPreset = prevPreset flagShowCmd = prevShowCommand flagConfigFile = prevConfigFile + presetResolved = prevPresetResolved }) } diff --git a/cmd/subcommands.go b/cmd/subcommands.go index a5257a815230db99d6426d572e6525618d88f3ab..af33d688d52932af2f2921106d130ad504ba822c 100644 --- a/cmd/subcommands.go +++ b/cmd/subcommands.go @@ -44,6 +44,24 @@ func registerSubcommands() { if wantsCommandHelp(args) { return cmd.Help() } + + // When invoked with no flags or arguments and the command + // is wrapped (has TUI screens), enter the interactive + // session with the command pre-selected, skipping the + // menu screen. + if len(args) == 0 && isWrappedCommand(commandName) { + cmdName, preset, overrides, err := runInteractive(commandName) + if err != nil { + return err + } + if cmdName == "" { + return nil + } + flagPreset = preset + presetResolved = true + return runCommand(cmdName, cmd, nil, overrides) + } + return runCommand(commandName, cmd, args, nil) }, } diff --git a/cmd/wrapspec.go b/cmd/wrapspec.go index d35e0069b818984cf3338f01449dc5258b8aed3f..914606f3db515b73649bfe30b9a73de0c7968ed8 100644 --- a/cmd/wrapspec.go +++ b/cmd/wrapspec.go @@ -1,6 +1,10 @@ package cmd -import "git.secluded.site/keld/internal/ui/screens" +import ( + "slices" + + "git.secluded.site/keld/internal/ui/screens" +) // wrappedCommand describes a restic command that keld exposes in its // interactive menu and shell completions. @@ -39,6 +43,12 @@ var knownCommands = func() []string { return names }() +// isWrappedCommand reports whether the given command name is one of +// the commands keld actively wraps with TUI screens. +func isWrappedCommand(name string) bool { + return slices.Contains(knownCommands, name) +} + // flagContract describes a restic flag that keld semantically depends on // (e.g. used in HasFlag checks or keyAliases). type flagContract struct { diff --git a/cmd/wrapspec_test.go b/cmd/wrapspec_test.go new file mode 100644 index 0000000000000000000000000000000000000000..35108c7256a548a92da98511419d79f8cfa77462 --- /dev/null +++ b/cmd/wrapspec_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestIsWrappedCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmd string + want bool + }{ + {name: "backup is wrapped", cmd: "backup", want: true}, + {name: "restore is wrapped", cmd: "restore", want: true}, + {name: "snapshots is wrapped", cmd: "snapshots", want: true}, + {name: "cat is not wrapped", cmd: "cat", want: false}, + {name: "ls is not wrapped", cmd: "ls", want: false}, + {name: "empty string", cmd: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := isWrappedCommand(tt.cmd) + if got != tt.want { + t.Errorf("isWrappedCommand(%q) = %v, want %v", tt.cmd, got, tt.want) + } + }) + } +} + +func TestSubcommandWithArgsStaysNonInteractive(t *testing.T) { + // Not parallel: mutates package-level flag vars via + // setRootFlagValuesForTest and environment via t.Setenv. + configFile := setupCommandConfig(t) + setRootFlagValuesForTest(t, "home@cloud", true, configFile) + t.Setenv("KELD_CONFIG_FILE", "") + t.Setenv("KELD_DRYRUN", "") + + backup := lookupSubcommand(t, "backup") + out, err := captureStdout(t, func() error { + return backup.RunE(backup, []string{"--tag", "daily", "/src"}) + }) + if err != nil { + t.Fatalf("subcommand with args returned error: %v", err) + } + + // Should produce dry-run output containing the command. + if !strings.Contains(out, `"backup"`) { + t.Fatalf("expected dry-run output to contain backup command, got:\n%s", out) + } + if !strings.Contains(out, `"--tag" "daily"`) { + t.Fatalf("expected dry-run output to contain --tag daily, got:\n%s", out) + } +}