1package cmd
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "slices"
8 "strings"
9
10 tea "charm.land/bubbletea/v2"
11 "charm.land/fang/v2"
12 "github.com/spf13/cobra"
13
14 "git.secluded.site/keld/internal/config"
15 "git.secluded.site/keld/internal/form"
16 "git.secluded.site/keld/internal/menu"
17 "git.secluded.site/keld/internal/restic"
18)
19
20var (
21 flagPreset string
22 flagShowCmd bool
23 flagConfigFile string
24)
25
26const overrideArgumentsKey = "_arguments"
27
28var rootCmd = &cobra.Command{
29 Use: "keld [keld flags] <command> [restic flags...]",
30 Short: "A friendly wrapper around restic",
31 Long: "keld resolves layered TOML config presets and executes restic with the merged result.",
32 Example: ` keld backup
33 keld --preset home backup
34 keld --preset home@nas backup --tag daily /src
35 keld --show-command --preset home backup
36 keld --config ./keld.toml --preset home backup
37 keld backup --help
38 keld`,
39
40 SilenceUsage: true,
41 SilenceErrors: true,
42 TraverseChildren: true,
43
44 RunE: func(cmd *cobra.Command, _ []string) error {
45 selected, err := runMenu()
46 if err != nil {
47 return fmt.Errorf("menu: %w", err)
48 }
49 if selected == "" || selected == "quit" {
50 return nil
51 }
52 if !slices.Contains(knownCommands, selected) {
53 return fmt.Errorf("unknown menu command %q", selected)
54 }
55
56 return runCommand(selected, cmd, nil)
57 },
58}
59
60func registerRootFlags() {
61 flags := rootCmd.PersistentFlags()
62 flags.StringVarP(&flagPreset, "preset", "P", "", "config preset to apply before running the command")
63 flags.BoolVar(&flagShowCmd, "show-command", false, "print the resolved restic command and exit")
64 flags.StringVarP(&flagConfigFile, "config", "C", "", "path to keld config file")
65}
66
67func runCommand(commandName string, cmd *cobra.Command, rawArgs []string) error {
68 if flagConfigFile != "" {
69 if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
70 return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
71 }
72 }
73 if flagShowCmd {
74 if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
75 return fmt.Errorf("setting KELD_DRYRUN: %w", err)
76 }
77 }
78
79 preset := flagPreset
80 interactive := cmd == cmd.Root()
81
82 if preset != "" {
83 if err := validatePreset(preset); err != nil {
84 return err
85 }
86 }
87
88 overrides := parsePassthrough(rawArgs)
89
90 // In interactive mode, fill missing preset/command inputs via prompts.
91 if interactive {
92 if preset == "" {
93 p, err := promptPreset()
94 if err != nil {
95 return err
96 }
97 preset = p
98 }
99
100 // Resolve config before prompting so we can skip questions for values
101 // already provided by presets.
102 peek, err := config.Resolve(preset, commandName, overrides)
103 if err != nil {
104 return err
105 }
106
107 cmdOverrides, err := promptForCommand(commandName, peek)
108 if err != nil {
109 return err
110 }
111 overrides = mergeOverrides(overrides, cmdOverrides)
112 }
113
114 cfg, err := config.Resolve(preset, commandName, overrides)
115 if err != nil {
116 return err
117 }
118
119 restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
120
121 if config.IsDryRun() {
122 fmt.Print(restic.DryRun(cfg))
123 return nil
124 }
125
126 return restic.Run(cfg)
127}
128
129func mergeOverrides(base, extra map[string][]string) map[string][]string {
130 if len(extra) == 0 {
131 return base
132 }
133 if base == nil {
134 base = make(map[string][]string, len(extra))
135 }
136 for key, values := range extra {
137 base[key] = values
138 }
139 return base
140}
141
142// Execute is the main entry point, called from main.go.
143func Execute() {
144 if err := fang.Execute(context.Background(), rootCmd); err != nil {
145 os.Exit(1)
146 }
147}
148
149// parsePassthrough converts restic-style CLI flags into a map suitable for
150// config.Resolve's cliOverrides parameter.
151func parsePassthrough(args []string) map[string][]string {
152 if len(args) == 0 {
153 return nil
154 }
155
156 overrides := make(map[string][]string)
157 var positional []string
158 for i := 0; i < len(args); i++ {
159 arg := args[i]
160 if arg == "--" {
161 if i+1 < len(args) {
162 positional = append(positional, args[i+1:]...)
163 }
164 break
165 }
166
167 if !isFlagToken(arg) {
168 positional = append(positional, arg)
169 continue
170 }
171
172 name := strings.TrimLeft(arg, "-")
173
174 // --flag=value form
175 if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 {
176 overrides[name[:eqIdx]] = append(overrides[name[:eqIdx]], name[eqIdx+1:])
177 continue
178 }
179
180 // Boolean flag (no next arg, or next arg is also a flag)
181 if i+1 >= len(args) || args[i+1] == "--" || isFlagToken(args[i+1]) {
182 if _, ok := overrides[name]; !ok {
183 overrides[name] = nil
184 }
185 continue
186 }
187
188 // --flag value form
189 i++
190 overrides[name] = append(overrides[name], args[i])
191 }
192
193 if len(positional) > 0 {
194 overrides[overrideArgumentsKey] = append([]string(nil), positional...)
195 }
196
197 if len(overrides) == 0 {
198 return nil
199 }
200
201 return overrides
202}
203
204func isFlagToken(arg string) bool {
205 return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
206}
207
208// runMenu launches the interactive BubbleTea menu and returns the chosen
209// command, or "" if the user quit.
210func runMenu() (string, error) {
211 m := menu.New(menuItems)
212 p := tea.NewProgram(m)
213 result, err := p.Run()
214 if err != nil {
215 return "", err
216 }
217 model, ok := result.(menu.Model)
218 if !ok {
219 return "", fmt.Errorf("unexpected menu model type %T", result)
220 }
221 return model.Choice(), nil
222}
223
224// validatePreset checks that the given preset name matches one of the usable
225// presets derived from the config. Returns a descriptive error if not found.
226func validatePreset(preset string) error {
227 known := config.Presets()
228 for _, p := range known {
229 if p == preset {
230 return nil
231 }
232 }
233 if len(known) == 0 {
234 return fmt.Errorf("unknown preset %q (no presets defined in config)", preset)
235 }
236 return fmt.Errorf("unknown preset %q; available presets: %s", preset, strings.Join(known, ", "))
237}
238
239// promptPreset shows an interactive preset selector when presets are defined
240// in the config. Returns "" (global-only) if no presets exist.
241func promptPreset() (string, error) {
242 presets := config.Presets()
243 if len(presets) == 0 {
244 return "", nil
245 }
246 selected, err := form.SelectPreset(presets)
247 if err != nil {
248 return "", fmt.Errorf("preset selection: %w", err)
249 }
250 return selected, nil
251}
252
253// promptForCommand collects required inputs for commands that need them.
254// The resolved config is checked first so prompts are skipped for values
255// the preset already provides.
256// Returns CLI overrides to merge, or nil if the command needs no extra input.
257func promptForCommand(command string, cfg *config.ResolvedConfig) (map[string][]string, error) {
258 switch command {
259 case "restore":
260 return promptRestore(cfg)
261 case "backup":
262 return promptBackup(cfg)
263 default:
264 return nil, nil
265 }
266}
267
268// promptRestore collects the snapshot ID and target directory for restore,
269// skipping prompts for values already present in the resolved config.
270func promptRestore(cfg *config.ResolvedConfig) (map[string][]string, error) {
271 hasSnapshotID := len(cfg.Arguments) > 0
272 hasTarget := cfg.HasFlag("target")
273
274 if hasSnapshotID && hasTarget {
275 return nil, nil
276 }
277
278 snapshotID, target, err := form.RestoreInputs(hasSnapshotID, hasTarget)
279 if err != nil {
280 return nil, fmt.Errorf("restore inputs: %w", err)
281 }
282
283 overrides := make(map[string][]string)
284 if !hasSnapshotID {
285 overrides[overrideArgumentsKey] = []string{snapshotID}
286 }
287 if !hasTarget {
288 overrides["target"] = []string{target}
289 }
290 return overrides, nil
291}
292
293// promptBackup collects backup paths when none are configured in the preset.
294func promptBackup(cfg *config.ResolvedConfig) (map[string][]string, error) {
295 if len(cfg.Arguments) > 0 {
296 return nil, nil
297 }
298
299 paths, err := form.BackupPaths()
300 if err != nil {
301 return nil, fmt.Errorf("backup paths: %w", err)
302 }
303 return map[string][]string{
304 overrideArgumentsKey: paths,
305 }, nil
306}