listremotes: add --exact flag for filtering - fixes #9076

This commit is contained in:
Anton Bordwine
2026-04-24 20:23:12 +05:00
committed by GitHub
parent 1f44b2ee93
commit 8e9ea05a67
2 changed files with 118 additions and 35 deletions

View File

@@ -20,6 +20,7 @@ import (
var (
listLong bool
jsonOutput bool
exactMatch bool
filterName string
filterType string
filterSource string
@@ -35,13 +36,66 @@ func init() {
flags.StringVarP(cmdFlags, &filterType, "type", "", "", "Filter remotes by type", "")
flags.StringVarP(cmdFlags, &filterSource, "source", "", "", "Filter remotes by source, e.g. 'file' or 'environment'", "")
flags.StringVarP(cmdFlags, &filterDescription, "description", "", "", "Filter remotes by description", "")
flags.BoolVarP(cmdFlags, &exactMatch, "exact", "", false, "Match filter strings exactly instead of using non-anchored glob matching", "")
flags.StringVarP(cmdFlags, &orderBy, "order-by", "", "", "Instructions on how to order the result, e.g. 'type,name=descending'", "")
flags.BoolVarP(cmdFlags, &jsonOutput, "json", "", false, "Format output as JSON", "")
}
// lessFn compares to remotes for order by
// lessFn compares two remotes for order by.
type lessFn func(a, b config.Remote) bool
// compileFilters compiles all configured filters into regexps.
func compileFilters(filterAll string, exact bool) (map[string]*regexp.Regexp, error) {
filters := make(map[string]*regexp.Regexp)
for k, v := range map[string]string{
"all": filterAll,
"name": filterName,
"type": filterType,
"source": filterSource,
"description": filterDescription,
} {
if v == "" {
continue
}
filterRe, err := filter.GlobStringToRegexp(v, exact, true)
if err != nil {
return nil, fmt.Errorf("invalid %s filter argument: %w", k, err)
}
fs.Debugf(nil, "Filter for %s: %s", k, filterRe.String())
filters[k] = filterRe
}
return filters, nil
}
// includeRemote returns true if remote matches all configured filters.
func includeRemote(remote config.Remote, filters map[string]*regexp.Regexp) bool {
for k, v := range filters {
switch k {
case "all":
if !(v.MatchString(remote.Name) || v.MatchString(remote.Type) || v.MatchString(remote.Source) || v.MatchString(remote.Description)) {
return false
}
case "name":
if !v.MatchString(remote.Name) {
return false
}
case "type":
if !v.MatchString(remote.Type) {
return false
}
case "source":
if !v.MatchString(remote.Source) {
return false
}
case "description":
if !v.MatchString(remote.Description) {
return false
}
}
}
return true
}
// newLess returns a function for comparing remotes based on an order by string
func newLess(orderBy string) (less lessFn, err error) {
if orderBy == "" {
@@ -125,53 +179,29 @@ the source (file or environment).
Result can be filtered by a filter argument which applies to all attributes,
and/or filter flags specific for each attribute. The values must be specified
according to regular rclone filtering pattern syntax.`,
according to regular rclone filtering pattern syntax.
By default filtering uses non-anchored matching, so ` + "`--type box`" + ` also
matches ` + "`dropbox`" + `. Use ` + "`--exact`" + ` to match complete values only.`,
Annotations: map[string]string{
"versionIntroduced": "v1.34",
},
RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(0, 1, command, args)
var filterDefault string
var filterAll string
if len(args) > 0 {
filterDefault = args[0]
filterAll = args[0]
}
filters := make(map[string]*regexp.Regexp)
for k, v := range map[string]string{
"all": filterDefault,
"name": filterName,
"type": filterType,
"source": filterSource,
"description": filterDescription,
} {
if v != "" {
filterRe, err := filter.GlobStringToRegexp(v, false, true)
if err != nil {
return fmt.Errorf("invalid %s filter argument: %w", k, err)
}
fs.Debugf(nil, "Filter for %s: %s", k, filterRe.String())
filters[k] = filterRe
}
filters, err := compileFilters(filterAll, exactMatch)
if err != nil {
return err
}
remotes := config.GetRemotes()
maxName := 0
maxType := 0
i := 0
for _, remote := range remotes {
include := true
for k, v := range filters {
if k == "all" && !(v.MatchString(remote.Name) || v.MatchString(remote.Type) || v.MatchString(remote.Source) || v.MatchString(remote.Description)) {
include = false
} else if k == "name" && !v.MatchString(remote.Name) {
include = false
} else if k == "type" && !v.MatchString(remote.Type) {
include = false
} else if k == "source" && !v.MatchString(remote.Source) {
include = false
} else if k == "description" && !v.MatchString(remote.Description) {
include = false
}
}
if include {
if includeRemote(remote, filters) {
if len(remote.Name) > maxName {
maxName = len(remote.Name)
}

View File

@@ -0,0 +1,53 @@
package ls
import (
"testing"
"github.com/rclone/rclone/fs/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func resetFilterFlags() {
filterName = ""
filterType = ""
filterSource = ""
filterDescription = ""
}
func TestTypeFilterDefaultIsFuzzy(t *testing.T) {
resetFilterFlags()
filterType = "box"
t.Cleanup(resetFilterFlags)
filters, err := compileFilters("", false)
require.NoError(t, err)
assert.True(t, includeRemote(config.Remote{Type: "box"}, filters))
assert.True(t, includeRemote(config.Remote{Type: "dropbox"}, filters))
}
func TestTypeFilterExactMatchesWholeValue(t *testing.T) {
resetFilterFlags()
filterType = "box"
t.Cleanup(resetFilterFlags)
filters, err := compileFilters("", true)
require.NoError(t, err)
assert.True(t, includeRemote(config.Remote{Type: "box"}, filters))
assert.True(t, includeRemote(config.Remote{Type: "BoX"}, filters))
assert.False(t, includeRemote(config.Remote{Type: "dropbox"}, filters))
}
func TestPositionalFilterExactAlsoMatchesWholeValue(t *testing.T) {
resetFilterFlags()
t.Cleanup(resetFilterFlags)
filters, err := compileFilters("box", true)
require.NoError(t, err)
assert.True(t, includeRemote(config.Remote{Type: "box"}, filters))
assert.False(t, includeRemote(config.Remote{Name: "mybox"}, filters))
assert.False(t, includeRemote(config.Remote{Description: "my dropbox remote"}, filters))
}