Validate required inputs in non-interactive mode

Amolith created

When running non-interactively, keld now validates that required inputs
are present before executing restic. Two cases checked:

- Multiple presets with no --preset specified: returns an error listing
available presets instead of silently using global defaults.
- backup with no paths: returns an error directing user to pass paths
as arguments or set _arguments in the preset.

Task: td-H0EDND6

Change summary

cmd/interactive.go                |   2 
cmd/interactive_test.go           |   2 
cmd/noninteractive_errors_test.go | 112 +++++++++++++++++++++++++++++++++
cmd/root.go                       |  16 ++++
cmd/root_noninteractive_test.go   |   2 
cmd/subcommands_test.go           |   3 
6 files changed, 132 insertions(+), 5 deletions(-)

Detailed changes

cmd/interactive.go 🔗

@@ -20,4 +20,4 @@ func isInteractive() bool {
 		return false
 	}
 	return isStdinTerminal()
-}
+}

cmd/noninteractive_errors_test.go 🔗

@@ -0,0 +1,112 @@
+package cmd
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestBackupNonInteractiveMissingPaths(t *testing.T) {
+	// Not parallel: mutates package-level isStdinTerminal.
+
+	original := isStdinTerminal
+	t.Cleanup(func() {
+		isStdinTerminal = original
+	})
+	isStdinTerminal = func() bool { return false }
+
+	// Config with a valid preset but no backup paths.
+	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"
+`), 0o600)
+	if err != nil {
+		t.Fatalf("writing fixture: %v", err)
+	}
+	t.Setenv("HOME", dir)
+
+	setRootFlagValuesForTest(t, "home@cloud", true, cfg)
+	t.Setenv("KELD_CONFIG_FILE", "")
+	t.Setenv("KELD_DRYRUN", "")
+
+	backup := lookupSubcommand(t, "backup")
+	_, err = captureStdout(t, func() error {
+		return backup.RunE(backup, []string{})
+	})
+
+	if err == nil {
+		t.Fatal("expected error for backup with no paths, got nil")
+	}
+
+	errMsg := err.Error()
+	// Should mention paths specifically.
+	if !strings.Contains(errMsg, "path") {
+		t.Fatalf("expected error to mention paths, got: %v", err)
+	}
+}
+
+func TestBackupNonInteractiveMultiplePresetsNoSelection(t *testing.T) {
+	// Not parallel: mutates package-level isStdinTerminal.
+
+	original := isStdinTerminal
+	t.Cleanup(func() {
+		isStdinTerminal = original
+	})
+	isStdinTerminal = func() bool { return false }
+
+	// Config with multiple presets (home@cloud, work@cloud).
+	dir := t.TempDir()
+	cfg := filepath.Join(dir, "config.toml")
+	err := os.WriteFile(cfg, []byte(`
+[global]
+repo = "/repos/default"
+
+["home@"]
+tag = "home"
+
+["home@".backup]
+_arguments = "/home"
+
+["work@"]
+tag = "work"
+
+["work@".backup]
+_arguments = "/work"
+
+["@cloud"]
+repo = "/repos/cloud"
+`), 0o600)
+	if err != nil {
+		t.Fatalf("writing fixture: %v", err)
+	}
+	t.Setenv("HOME", dir)
+
+	// No --preset specified.
+	setRootFlagValuesForTest(t, "", true, cfg)
+	t.Setenv("KELD_CONFIG_FILE", "")
+	t.Setenv("KELD_DRYRUN", "")
+
+	backup := lookupSubcommand(t, "backup")
+	_, err = captureStdout(t, func() error {
+		return backup.RunE(backup, []string{})
+	})
+
+	if err == nil {
+		t.Fatal("expected error for multiple presets with no selection, got nil")
+	}
+
+	errMsg := err.Error()
+	// Should mention multiple presets.
+	if !strings.Contains(errMsg, "preset") {
+		t.Fatalf("expected error to mention presets, got: %v", err)
+	}
+}

cmd/root.go 🔗

@@ -100,6 +100,15 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessio
 		preset = *sessionPreset
 	}
 
+	// In non-interactive mode with no preset and multiple presets
+	// available, require explicit --preset selection.
+	if !interactive && preset == "" {
+		presets := config.Presets()
+		if len(presets) > 1 {
+			return fmt.Errorf("keld: multiple presets defined (%s); specify --preset in non-interactive mode", strings.Join(presets, ", "))
+		}
+	}
+
 	if preset != "" {
 		if err := validatePreset(preset); err != nil {
 			return err
@@ -131,6 +140,13 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessio
 		return err
 	}
 
+	// In non-interactive mode, validate that required inputs are present.
+	if !interactive {
+		if commandName == "backup" && len(cfg.Arguments) == 0 {
+			return fmt.Errorf("keld backup: no paths specified; pass paths as arguments or set _arguments in the preset")
+		}
+	}
+
 	restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
 
 	if config.IsDryRun() {

cmd/root_noninteractive_test.go 🔗

@@ -35,4 +35,4 @@ func TestRootRunNonInteractiveRequiresSubcommand(t *testing.T) {
 	if !strings.Contains(errMsg, "subcommand") {
 		t.Fatalf("expected error to mention subcommand, got: %v", err)
 	}
-}
+}

cmd/subcommands_test.go 🔗

@@ -27,7 +27,6 @@ func TestSubcommandNoArgsNonInteractiveRunsDirectly(t *testing.T) {
 	out, err := captureStdout(t, func() error {
 		return backup.RunE(backup, []string{})
 	})
-
 	// Should NOT get a bubbletea TTY error. Instead, should either:
 	// - Succeed with dry-run output (if config has paths), or
 	// - Fail with a sensible error about missing inputs.
@@ -112,4 +111,4 @@ func TestSubcommandNoArgsInteractiveEntersSession(t *testing.T) {
 	if !strings.Contains(errMsg, "TTY") && !strings.Contains(errMsg, "tty") {
 		t.Fatalf("expected bubbletea TTY error in interactive mode, got: %v", err)
 	}
-}
+}