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

This commit is contained in:
Anton Bordwine
2026-04-24 16:23:12 +01:00
committed by GitHub
parent 1f44b2ee93
commit 8e9ea05a67
2 changed files with 118 additions and 35 deletions
+65 -35
View File
@@ -20,6 +20,7 @@ import (
var ( var (
listLong bool listLong bool
jsonOutput bool jsonOutput bool
exactMatch bool
filterName string filterName string
filterType string filterType string
filterSource string filterSource string
@@ -35,13 +36,66 @@ func init() {
flags.StringVarP(cmdFlags, &filterType, "type", "", "", "Filter remotes by type", "") 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, &filterSource, "source", "", "", "Filter remotes by source, e.g. 'file' or 'environment'", "")
flags.StringVarP(cmdFlags, &filterDescription, "description", "", "", "Filter remotes by description", "") 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.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", "") 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 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 // newLess returns a function for comparing remotes based on an order by string
func newLess(orderBy string) (less lessFn, err error) { func newLess(orderBy string) (less lessFn, err error) {
if orderBy == "" { if orderBy == "" {
@@ -125,53 +179,29 @@ the source (file or environment).
Result can be filtered by a filter argument which applies to all attributes, 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 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{ Annotations: map[string]string{
"versionIntroduced": "v1.34", "versionIntroduced": "v1.34",
}, },
RunE: func(command *cobra.Command, args []string) error { RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(0, 1, command, args) cmd.CheckArgs(0, 1, command, args)
var filterDefault string var filterAll string
if len(args) > 0 { if len(args) > 0 {
filterDefault = args[0] filterAll = args[0]
} }
filters := make(map[string]*regexp.Regexp) filters, err := compileFilters(filterAll, exactMatch)
for k, v := range map[string]string{ if err != nil {
"all": filterDefault, return err
"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
}
} }
remotes := config.GetRemotes() remotes := config.GetRemotes()
maxName := 0 maxName := 0
maxType := 0 maxType := 0
i := 0 i := 0
for _, remote := range remotes { for _, remote := range remotes {
include := true if includeRemote(remote, filters) {
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 len(remote.Name) > maxName { if len(remote.Name) > maxName {
maxName = len(remote.Name) maxName = len(remote.Name)
} }
+53
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))
}