Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions cmd/cloudstic/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand All @@ -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://')
Expand Down Expand Up @@ -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:'
Expand Down Expand Up @@ -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
Expand All @@ -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' \
Expand Down Expand Up @@ -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' \
Expand Down Expand Up @@ -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

Expand All @@ -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:<path>, s3:<bucket>[/<prefix>], b2:<bucket>[/<prefix>], sftp://[user@]host[:port]/<path>)'
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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
124 changes: 124 additions & 0 deletions cmd/cloudstic/completion_dynamic.go
Original file line number Diff line number Diff line change
@@ -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, ""
}
97 changes: 97 additions & 0 deletions cmd/cloudstic/completion_dynamic_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading