From ff8daec51dd6c324ca1964916aef64d765880235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 22:26:48 +0200 Subject: [PATCH] Add dynamic completion for profile and auth refs --- cmd/cloudstic/completion.go | 62 ++++++++++-- cmd/cloudstic/completion_dynamic.go | 124 +++++++++++++++++++++++ cmd/cloudstic/completion_dynamic_test.go | 97 ++++++++++++++++++ cmd/cloudstic/completion_test.go | 9 ++ cmd/cloudstic/main.go | 2 + 5 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 cmd/cloudstic/completion_dynamic.go create mode 100644 cmd/cloudstic/completion_dynamic_test.go diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 752229a..1e9935b 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -37,6 +37,13 @@ func runCompletion() { func completionBash(w io.Writer) { _, _ = fmt.Fprint(w, `# bash completion for cloudstic +_cloudstic_query() { + local kind="$1" + local cur="$2" + shift 2 + cloudstic __complete "$kind" "$cur" "$@" 2>/dev/null +} + _cloudstic() { local cur prev words cword _init_completion || return @@ -241,6 +248,12 @@ _cloudstic() { # Value completions for specific flags case "$prev" in + -profile) + COMPREPLY=($(compgen -W "$(_cloudstic_query profile-names "$cur" "${words[@]:1:$((cword-1))}")" -- "$cur")) + return ;; + -auth-ref) + COMPREPLY=($(compgen -W "$(_cloudstic_query auth-names "$cur" "${words[@]:1:$((cword-1))}")" -- "$cur")) + return ;; -store) # URI completion hint: show scheme prefixes COMPREPLY=($(compgen -W "local: s3: b2: sftp://" -- "$cur")) @@ -265,6 +278,30 @@ func completionZsh(w io.Writer) { # zsh completion for cloudstic +_cloudstic_query() { + local kind="$1" + shift + local -a prior_words + prior_words=("${words[@]:2:$(( CURRENT - 2 ))}") + cloudstic __complete "$kind" "$PREFIX" "${prior_words[@]}" 2>/dev/null +} + +_cloudstic_dynamic_values() { + local kind="$1" + local label="$2" + local -a values + values=(${(f)"$(_cloudstic_query "$kind")"}) + _describe -t "$kind" "$label" values +} + +_cloudstic_profile_names() { + _cloudstic_dynamic_values profile-names 'profile' +} + +_cloudstic_auth_names() { + _cloudstic_dynamic_values auth-names 'auth entry' +} + _cloudstic_store_prefixes() { local -a values values=('local:' 's3:' 'b2:' 'sftp://') @@ -294,10 +331,12 @@ _cloudstic() { 'help:Show usage information' ) + local prev_word="${words[CURRENT-1]}" + local -a global_flags global_flags=( '-store[Storage backend URI]:uri:_cloudstic_store_prefixes' - '-profile[Profile name from profiles.yaml]:name:' + '-profile[Profile name from profiles.yaml]:name:_cloudstic_profile_names' '-profiles-file[Path to profiles YAML file]:path:_files' '-s3-endpoint[S3 compatible endpoint URL]:url:' '-s3-region[S3 region]:region:' @@ -347,6 +386,12 @@ _cloudstic() { done if [[ -z "$cmd" ]]; then + case "$prev_word" in + -store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-source-sftp-known-hosts|-store-sftp-password|-store-sftp-key|-store-sftp-known-hosts|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint) + _arguments $global_flags + return + ;; + esac _describe -t commands 'cloudstic command' commands _arguments $global_flags return @@ -364,7 +409,7 @@ _cloudstic() { '-source[Source URI]:uri:(local: sftp:// gdrive gdrive-changes onedrive onedrive-changes)' \ '-profile[Backup profile name]:name:' \ '-all-profiles[Run all enabled backup profiles]' \ - '-auth-ref[Use named auth entry from profiles.yaml]:name:' \ + '-auth-ref[Use named auth entry from profiles.yaml]:name:_cloudstic_auth_names' \ '-profiles-file[Path to profiles YAML file]:path:_files' \ '-skip-native-files[Exclude Google-native files]' \ '-google-credentials[Google service account credentials JSON]:path:_files' \ @@ -417,7 +462,7 @@ _cloudstic() { '-source[Source URI]:uri:(local: sftp:// gdrive gdrive-changes onedrive onedrive-changes)' \ '-store-ref[Store reference name]:name:' \ '-store[Store URI]:uri:' \ - '-auth-ref[Auth reference name]:name:' \ + '-auth-ref[Auth reference name]:name:_cloudstic_auth_names' \ '*-tag[Tag for snapshots]:tag:' \ '*-exclude[Exclude pattern]:pattern:' \ '-exclude-file[Path to exclude file]:path:_files' \ @@ -705,6 +750,11 @@ compdef _cloudstic cloudstic func completionFish(w io.Writer) { _, _ = fmt.Fprint(w, `# fish completion for cloudstic +function __fish_cloudstic_query + set -l kind $argv[1] + cloudstic __complete $kind (commandline -ct) (commandline -opc) 2>/dev/null +end + # Disable file completions by default complete -c cloudstic -f @@ -730,7 +780,7 @@ complete -c cloudstic -n __fish_use_subcommand -a help -d 'Show usage informatio # Global flags (available for all subcommands) complete -c cloudstic -l store -x -d 'Storage backend URI (local:, s3:[/], b2:[/], sftp://[user@]host[:port]/)' -complete -c cloudstic -l profile -x -d 'Profile name from profiles.yaml' +complete -c cloudstic -l profile -x -a '(__fish_cloudstic_query profile-names)' -d 'Profile name from profiles.yaml' complete -c cloudstic -l profiles-file -r -F -d 'Path to profiles YAML file' complete -c cloudstic -l s3-endpoint -x -d 'S3 compatible endpoint URL' complete -c cloudstic -l s3-region -x -d 'S3 region' @@ -768,7 +818,7 @@ complete -c cloudstic -n '__fish_seen_subcommand_from init' -l adopt-slots -d 'A complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l source -x -a 'local: sftp:// gdrive gdrive-changes onedrive onedrive-changes' -d 'Source URI' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l profile -x -d 'Backup profile name' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l all-profiles -d 'Run all enabled backup profiles' -complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l auth-ref -x -d 'Use named auth entry from profiles.yaml' +complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l auth-ref -x -a '(__fish_cloudstic_query auth-names)' -d 'Use named auth entry from profiles.yaml' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l profiles-file -r -F -d 'Path to profiles YAML file' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-native-files -d 'Exclude Google-native files' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l google-credentials -r -F -d 'Google service account credentials JSON' @@ -798,7 +848,7 @@ complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_s complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l source -x -a 'local: sftp:// gdrive gdrive-changes onedrive onedrive-changes' -d 'Source URI' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l store-ref -x -d 'Store reference name' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l store -x -d 'Store URI' -complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l auth-ref -x -d 'Auth reference name' +complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l auth-ref -x -a '(__fish_cloudstic_query auth-names)' -d 'Auth reference name' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l tag -x -d 'Tag for snapshots' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l exclude -x -d 'Exclude pattern' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l exclude-file -r -F -d 'Path to exclude file' diff --git a/cmd/cloudstic/completion_dynamic.go b/cmd/cloudstic/completion_dynamic.go new file mode 100644 index 0000000..3a47833 --- /dev/null +++ b/cmd/cloudstic/completion_dynamic.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + + cloudstic "github.com/cloudstic/cli" +) + +var completionLoadProfilesFile = cloudstic.LoadProfilesFile + +func runCompletionQuery(ctx context.Context) int { + if len(os.Args) < 3 { + return 0 + } + kind := os.Args[2] + current := "" + if len(os.Args) > 3 { + current = os.Args[3] + } + candidates, err := completionCandidates(ctx, kind, current, os.Args[4:]) + if err != nil { + return 0 + } + for _, candidate := range candidates { + _, _ = fmt.Fprintln(os.Stdout, candidate) + } + return 0 +} + +func completionCandidates(_ context.Context, kind, _ string, args []string) ([]string, error) { + switch kind { + case "profile-names": + return completionProfileNames(args) + case "auth-names": + return completionAuthNames(args) + default: + return nil, nil + } +} + +func completionProfileNames(args []string) ([]string, error) { + cfg, err := completionLoadProfilesConfig(completionProfilesPath(args)) + if err != nil { + return nil, err + } + return sortedKeys(cfg.Profiles), nil +} + +func completionAuthNames(args []string) ([]string, error) { + cfg, err := completionLoadProfilesConfig(completionProfilesPath(args)) + if err != nil { + return nil, err + } + return sortedKeys(cfg.Auth), nil +} + +func completionLoadProfilesConfig(path string) (*cloudstic.ProfilesConfig, error) { + cfg, err := completionLoadProfilesFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &cloudstic.ProfilesConfig{Version: 1}, nil + } + return nil, err + } + ensureProfilesMaps(cfg) + return cfg, nil +} + +func completionProfilesPath(args []string) string { + fs := flag.NewFlagSet("__complete", flag.ContinueOnError) + fs.SetOutput(io.Discard) + defaultPath := envDefault("CLOUDSTIC_PROFILES_FILE", defaultProfilesPathFallback()) + profilesFile := fs.String("profiles-file", defaultPath, "") + _ = fs.Parse(filterCompletionFlags(args, map[string]bool{ + "profiles-file": true, + })) + return *profilesFile +} + +func filterCompletionFlags(args []string, specs map[string]bool) []string { + filtered := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if len(arg) == 0 || arg[0] != '-' { + continue + } + name, hasValue, value := splitCompletionFlag(arg) + takesValue, ok := specs[name] + if !ok { + continue + } + if hasValue { + filtered = append(filtered, arg) + continue + } + filtered = append(filtered, arg) + if takesValue && i+1 < len(args) { + filtered = append(filtered, args[i+1]) + i++ + } + if !takesValue && value != "" { + continue + } + } + return filtered +} + +func splitCompletionFlag(arg string) (name string, hasValue bool, value string) { + trimmed := arg + for len(trimmed) > 0 && trimmed[0] == '-' { + trimmed = trimmed[1:] + } + for i := 0; i < len(trimmed); i++ { + if trimmed[i] == '=' { + return trimmed[:i], true, trimmed[i+1:] + } + } + return trimmed, false, "" +} diff --git a/cmd/cloudstic/completion_dynamic_test.go b/cmd/cloudstic/completion_dynamic_test.go new file mode 100644 index 0000000..828b5fe --- /dev/null +++ b/cmd/cloudstic/completion_dynamic_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "io" + "os" + "path/filepath" + "reflect" + "testing" + + cloudstic "github.com/cloudstic/cli" +) + +func TestCompletionCandidates_ProfileAndAuthNames(t *testing.T) { + profilesPath := filepath.Join(t.TempDir(), "profiles.yaml") + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Profiles: map[string]cloudstic.BackupProfile{ + "laptop": {}, + "server": {}, + }, + Auth: map[string]cloudstic.ProfileAuth{ + "google-work": {}, + "ms-personal": {}, + }, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + profiles, err := completionCandidates(context.Background(), "profile-names", "", []string{"backup", "-profiles-file", profilesPath}) + if err != nil { + t.Fatalf("completionCandidates(profile-names): %v", err) + } + if want := []string{"laptop", "server"}; !reflect.DeepEqual(profiles, want) { + t.Fatalf("profile names = %#v, want %#v", profiles, want) + } + + auth, err := completionCandidates(context.Background(), "auth-names", "", []string{"backup", "-profiles-file", profilesPath}) + if err != nil { + t.Fatalf("completionCandidates(auth-names): %v", err) + } + if want := []string{"google-work", "ms-personal"}; !reflect.DeepEqual(auth, want) { + t.Fatalf("auth names = %#v, want %#v", auth, want) + } +} + +func TestCompletionCandidates_MissingProfilesFileIsEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.yaml") + got, err := completionCandidates(context.Background(), "profile-names", "", []string{"backup", "-profiles-file", path}) + if err != nil { + t.Fatalf("completionCandidates: %v", err) + } + if len(got) != 0 { + t.Fatalf("profile names = %#v, want empty", got) + } +} + +func TestRunCompletionQuery_WritesCandidates(t *testing.T) { + oldLoad := completionLoadProfilesFile + completionLoadProfilesFile = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Profiles: map[string]cloudstic.BackupProfile{ + "work": {}, + }, + }, nil + } + t.Cleanup(func() { completionLoadProfilesFile = oldLoad }) + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"cloudstic", "__complete", "profile-names", "", "backup"} + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = r.Close() }() + defer func() { _ = w.Close() }() + + oldStdout := os.Stdout + t.Cleanup(func() { os.Stdout = oldStdout }) + os.Stdout = w + + if code := runCompletionQuery(context.Background()); code != 0 { + t.Fatalf("runCompletionQuery code = %d, want 0", code) + } + _ = w.Close() + + data, readErr := io.ReadAll(r) + if readErr != nil { + t.Fatalf("ReadAll: %v", readErr) + } + if string(data) != "work\n" { + t.Fatalf("stdout = %q, want %q", string(data), "work\n") + } +} diff --git a/cmd/cloudstic/completion_test.go b/cmd/cloudstic/completion_test.go index 852dd36..4d1c68c 100644 --- a/cmd/cloudstic/completion_test.go +++ b/cmd/cloudstic/completion_test.go @@ -18,6 +18,7 @@ func TestCompletionBash(t *testing.T) { // Verify it's a valid bash completion script for _, marker := range []string{ "_cloudstic()", + "_cloudstic_query()", "complete -F _cloudstic cloudstic", // All commands are listed "init", "backup", "auth", "profile", "store", "source", "setup", "restore", "list", "ls", "prune", "forget", @@ -31,6 +32,8 @@ func TestCompletionBash(t *testing.T) { "-profiles-file", "-profile", "-all-profiles", "-auth-ref", + "profile-names", + "auth-names", "-ignore-empty-snapshot", "workstation", "-store-ref", @@ -56,6 +59,9 @@ func TestCompletionZsh(t *testing.T) { for _, marker := range []string{ "#compdef cloudstic", "_cloudstic()", + "_cloudstic_query()", + "_cloudstic_profile_names", + "_cloudstic_auth_names", "_cloudstic_store_prefixes()", "compdef _cloudstic cloudstic", // Commands with descriptions @@ -112,6 +118,7 @@ func TestCompletionFish(t *testing.T) { for _, marker := range []string{ "complete -c cloudstic -f", + "function __fish_cloudstic_query", // Subcommands "complete -c cloudstic -n __fish_use_subcommand -a init", "complete -c cloudstic -n __fish_use_subcommand -a backup", @@ -140,6 +147,8 @@ func TestCompletionFish(t *testing.T) { "-l profile", "-l all-profiles", "-l auth-ref", + "(__fish_cloudstic_query profile-names)", + "(__fish_cloudstic_query auth-names)", "-l ignore-empty-snapshot", "-a workstation -d 'Preview workstation onboarding plan'", "-l store-ref", diff --git a/cmd/cloudstic/main.go b/cmd/cloudstic/main.go index 3a9aaca..bef8d21 100644 --- a/cmd/cloudstic/main.go +++ b/cmd/cloudstic/main.go @@ -78,6 +78,8 @@ func runCmd(cmd string) int { case "completion": runCompletion() return 0 + case "__complete": + return runCompletionQuery(ctx) case "help", "--help", "-h": printUsage() return 0