diff --git a/cmd/delete-db.go b/cmd/delete-db.go new file mode 100644 index 0000000..049b874 --- /dev/null +++ b/cmd/delete-db.go @@ -0,0 +1,94 @@ +/* +Copyright © 2025 Lewis Wynne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package cmd + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "os" +) + +// delDbCmd represents the set command +var delDbCmd = &cobra.Command{ + Use: "delete-db DB", + Short: "Delete a database.", + Args: cobra.ExactArgs(1), + RunE: delDb, +} + +func delDb(cmd *cobra.Command, args []string) error { + store := &Store{} + var notFound errNotFound + path, err := store.FindStore(args[0]) + if errors.As(err, ¬Found) { + fmt.Fprintf(os.Stderr, "%q does not exist, %s\n", args[0], err.Error()) + os.Exit(1) + } + if err != nil { + fmt.Fprintf(os.Stderr, "unexpected error: %s", err.Error()) + os.Exit(1) + } + + var confirm string + home, err := os.UserHomeDir() + nicepath := path + if err == nil && strings.HasPrefix(path, home) { + nicepath = filepath.Join("~", strings.TrimPrefix(nicepath, home)) + } + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + + if force { + return executeDeletion(path, nicepath) + } + + message := fmt.Sprintf("Are you sure you want to delete '%s'? (y/n)", nicepath) + fmt.Println(message) + if _, err := fmt.Scanln(&confirm); err != nil { + return err + } + if strings.ToLower(confirm) == "y" { + return executeDeletion(path, nicepath) + } + fmt.Fprintf(os.Stderr, "Did not delete %q\n", nicepath) + return nil +} + +func executeDeletion(path, nicepath string) error { + if err := os.RemoveAll(path); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Deleted %q\n", nicepath) + return nil +} + +func init() { + delDbCmd.Flags().BoolP("force", "f", false, "Force delete without confirmation") + rootCmd.AddCommand(delDbCmd) +} diff --git a/cmd/shared.go b/cmd/shared.go index 8d80d13..d5c2cdb 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -23,16 +23,29 @@ package cmd import ( "fmt" + "math" "os" "path/filepath" "strings" "unicode/utf8" + "github.com/agnivade/levenshtein" "github.com/dgraph-io/badger/v4" gap "github.com/muesli/go-app-paths" "golang.org/x/term" ) +type errNotFound struct { + suggestions []string +} + +func (err errNotFound) Error() string { + if len(err.suggestions) == 0 { + return "no suggestions found" + } + return fmt.Sprintf("did you mean %q", strings.Join(err.suggestions, ", ")) +} + type Store struct{} type TransactionArgs struct { @@ -43,7 +56,7 @@ type TransactionArgs struct { } func (s *Store) Transaction(args TransactionArgs) error { - k, dbName, err := s.parse(args.key) + k, dbName, err := s.parse(args.key, true) if err != nil { return err } @@ -69,44 +82,6 @@ func (s *Store) Transaction(args TransactionArgs) error { return tx.Commit() } -func (s *Store) parse(k string) ([]byte, string, error) { - var key, db string - ps := strings.Split(k, "@") - switch len(ps) { - case 1: - key = strings.ToLower(ps[0]) - case 2: - key = strings.ToLower(ps[0]) - db = strings.ToLower(ps[1]) - default: - return nil, "", fmt.Errorf("bad key format, use KEY@DB") - } - return []byte(key), db, nil -} - -func (s *Store) open(name string) (*badger.DB, error) { - if name == "" { - name = "default" - } - path, err := s.path(name) - if err != nil { - return nil, err - } - return badger.Open(badger.DefaultOptions(path).WithLoggingLevel(badger.ERROR)) -} - -func (s *Store) path(args ...string) (string, error) { - scope := gap.NewVendorScope(gap.User, "pda", "stores") - dir, err := scope.DataPath("") - if err != nil { - return "", err - } - if err := os.MkdirAll(dir, 0o750); err != nil { - return "", err - } - return filepath.Join(append([]string{dir}, args...)...), nil -} - func (s *Store) Print(pf string, vs ...[]byte) { nb := "(omitted binary data)" fvs := make([]any, 0) @@ -141,3 +116,80 @@ func (s *Store) AllStores() ([]string, error) { } return stores, nil } + +func (s *Store) FindStore(k string) (string, error) { + _, n, err := s.parse(k, false) + if err != nil { + return "", err + } + path, err := s.path(n) + if err != nil { + return "", err + } + _, err = os.Stat(path) + if strings.TrimSpace(n) == "" || os.IsNotExist(err) { + stores, err := s.AllStores() + if err != nil { + return "", err + } + var suggestions []string + minThreshold := 1 + maxThreshold := 4 + threshold := len(n) / 3 + if threshold < minThreshold { + threshold = minThreshold + } + if threshold > maxThreshold { + threshold = maxThreshold + } + for _, store := range stores { + distance := levenshtein.ComputeDistance(n, store) + if distance <= threshold { + suggestions = append(suggestions, store) + } + } + return "", errNotFound{suggestions} + } + return path, nil +} + +func (s *Store) parse(k string, defaults bool) ([]byte, string, error) { + var key, db string + ps := strings.Split(k, "@") + switch len(ps) { + case 1: + key = strings.ToLower(ps[0]) + if defaults { + db = "default" + } + case 2: + key = strings.ToLower(ps[0]) + db = strings.ToLower(ps[1]) + default: + return nil, "", fmt.Errorf("bad key format, use KEY@DB") + } + return []byte(key), db, nil +} + +func (s *Store) open(name string) (*badger.DB, error) { + if name == "" { + name = "default" + } + path, err := s.path(name) + if err != nil { + return nil, err + } + return badger.Open(badger.DefaultOptions(path).WithLoggingLevel(badger.ERROR)) +} + +func (s *Store) path(args ...string) (string, error) { + scope := gap.NewVendorScope(gap.User, "pda", "stores") + dir, err := scope.DataPath("") + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return "", err + } + return filepath.Join(append([]string{dir}, args...)...), nil +} diff --git a/go.mod b/go.mod index 38250b0..1592fd8 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/llywelwyn/pda go 1.25.3 require ( + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgraph-io/badger/v4 v4.8.0 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect diff --git a/go.sum b/go.sum index 23dab8a..0302ed2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=