Repository: spf13/viper (go)

Issue:
Replace file searching API with a finder
### Preflight Checklist

- [X] I have searched the [issue tracker](https://www.github.com/spf13/viper/issues) for an issue that matches the one I want to file, without success.

### Problem Description

The current API for file searching is extremely confusing, hard to explain and is full of edge cases that people rely on and then open issues/PRs with "fixes" when they break.

Examples:

- #1754
- #1401

The current implementation is also not very extensible (people can't workaround issues).

### Proposed Solution

Deprecate the current API (`SetConfigName`, `AddConfigPath`) and replace it with a `Finder` interface:

```go
type Finder interface {
    Find(fsys afero.FS) ([]string, error)
}
```

`Finder` should return a list of file paths that match a certain search criteria in the specific filesystem. (Viper uses afero as a filesystem abstraction under the hood. I don't see that going away in the short run, ie. we can't easily replace it with io/fs, so I believe it's safe to use it in the new interface)

How that search criteria is applied is up to the implementation which allows users to extend the file search implementation.

The reference implementation for `Finder` is [locafero](https://github.com/sagikazarmark/locafero). To remain backwards compatible on the v1 branch we can use locafero under the hood.

### Alternatives Considered

_No response_

### Additional Information

_No response_

Pull request:
Replace file searching API with a finder
Fixes #1795

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 95aa237..cc42da7 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -45,7 +45,7 @@ jobs:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
         go: ["1.21", "1.22"]
-        tags: ["", "finder", "viper_bind_struct"]
+        tags: ["", "viper_finder", "viper_bind_struct"]
 
     steps:
       - name: Checkout repository
diff --git a/file.go b/file.go
index a54fe5a..101624d 100644
--- a/file.go
+++ b/file.go
@@ -1,4 +1,4 @@
-//go:build !finder
+//go:build !viper_finder
 
 package viper
 
diff --git a/file_finder.go b/file_finder.go
index d96a1bd..c873621 100644
--- a/file_finder.go
+++ b/file_finder.go
@@ -1,4 +1,4 @@
-//go:build finder
+//go:build viper_finder
 
 package viper
 
@@ -11,18 +11,22 @@ import (
 // Search all configPaths for any config file.
 // Returns the first path that exists (and is a config file).
 func (v *Viper) findConfigFile() (string, error) {
-	var names []string
-
-	if v.configType != "" {
-		names = locafero.NameWithOptionalExtensions(v.configName, SupportedExts...)
-	} else {
-		names = locafero.NameWithExtensions(v.configName, SupportedExts...)
-	}
-
-	finder := locafero.Finder{
-		Paths: v.configPaths,
-		Names: names,
-		Type:  locafero.FileTypeFile,
+	finder := v.finder
+
+	if finder == nil {
+		var names []string
+
+		if v.configType != "" {
+			names = locafero.NameWithOptionalExtensions(v.configName, SupportedExts...)
+		} else {
+			names = locafero.NameWithExtensions(v.configName, SupportedExts...)
+		}
+
+		finder = locafero.Finder{
+			Paths: v.configPaths,
+			Names: names,
+			Type:  locafero.FileTypeFile,
+		}
 	}
 
 	results, err := finder.Find(v.fs)
diff --git a/finder.go b/finder.go
new file mode 100644
index 0000000..ab4b595
--- /dev/null
+++ b/finder.go
@@ -0,0 +1,47 @@
+package viper
+
+import (
+	"errors"
+
+	"github.com/spf13/afero"
+)
+
+// WithFinder sets a custom [Finder].
+func WithFinder(f Finder) Option {
+	return optionFunc(func(v *Viper) {
+		v.finder = f
+	})
+}
+
+// Finder looks for files and directories in an [afero.Fs] filesystem.
+type Finder interface {
+	Find(fsys afero.Fs) ([]string, error)
+}
+
+// Finders combines multiple finders into one.
+func Finders(finders ...Finder) Finder {
+	return &combinedFinder{finders: finders}
+}
+
+// combinedFinder is a Finder that combines multiple finders.
+type combinedFinder struct {
+	finders []Finder
+}
+
+// Find implements the [Finder] interface.
+func (c *combinedFinder) Find(fsys afero.Fs) ([]string, error) {
+	var results []string
+	var errs []error
+
+	for _, finder := range c.finders {
+		r, err := finder.Find(fsys)
+		if err != nil {
+			errs = append(errs, err)
+			continue
+		}
+
+		results = append(results, r...)
+	}
+
+	return results, errors.Join(errs...)
+}
diff --git a/go.mod b/go.mod
index ed77300..2cc6b08 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
 	github.com/magiconair/properties v1.8.7
 	github.com/pelletier/go-toml/v2 v2.2.2
 	github.com/sagikazarmark/crypt v0.20.0
-	github.com/sagikazarmark/locafero v0.4.0
+	github.com/sagikazarmark/locafero v0.5.0
 	github.com/spf13/afero v1.11.0
 	github.com/spf13/cast v1.6.0
 	github.com/spf13/pflag v1.0.5
diff --git a/go.sum b/go.sum
index 6c93eb4..5b1e700 100644
--- a/go.sum
+++ b/go.sum
@@ -246,8 +246,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
 github.com/sagikazarmark/crypt v0.20.0 h1:aMLNL5RDUyk+/Bmoql3iYbv6C75/H0qvs6CWHzRGo6Y=
 github.com/sagikazarmark/crypt v0.20.0/go.mod h1:ojgvzJOpJkRhzSrycMyk8cgctQgyfZN/d2/dVh4U68c=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/locafero v0.5.0 h1:zXz2JnQDgE5gDg0R9ThkNT0orQzm47i8IuO6hk6XSYY=
+github.com/sagikazarmark/locafero v0.5.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
diff --git a/viper.go b/viper.go
index cebdde6..472fd59 100644
--- a/viper.go
+++ b/viper.go
@@ -158,6 +158,8 @@ type Viper struct {
 	// The filesystem to read config from.
 	fs afero.Fs
 
+	finder Finder
+
 	// A set of remote providers to search for the configuration
 	remoteProviders []*defaultRemoteProvider
 
@@ -506,6 +508,10 @@ func (v *Viper) ConfigFileUsed() string { return v.configFile }
 func AddConfigPath(in string) { v.AddConfigPath(in) }
 
 func (v *Viper) AddConfigPath(in string) {
+	if v.finder != nil {
+		v.logger.Warn("ineffective call to function: custom finder takes precedence", slog.String("function", "AddConfigPath"))
+	}
+
 	if in != "" {
 		absin := absPathify(v.logger, in)
 
@@ -1955,6 +1961,10 @@ func (v *Viper) SetFs(fs afero.Fs) {
 func SetConfigName(in string) { v.SetConfigName(in) }
 
 func (v *Viper) SetConfigName(in string) {
+	if v.finder != nil {
+		v.logger.Warn("ineffective call to function: custom finder takes precedence", slog.String("function", "SetConfigName"))
+	}
+
 	if in != "" {
 		v.configName = in
 		v.configFile = ""


Test patch:
diff --git a/finder_example_test.go b/finder_example_test.go
new file mode 100644
index 0000000..76c5f57
--- /dev/null
+++ b/finder_example_test.go
@@ -0,0 +1,75 @@
+//go:build viper_finder
+
+package viper_test
+
+import (
+	"fmt"
+
+	"github.com/sagikazarmark/locafero"
+	"github.com/spf13/afero"
+
+	"github.com/spf13/viper"
+)
+
+func ExampleFinder() {
+	fs := afero.NewMemMapFs()
+
+	fs.Mkdir("/home/user", 0o777)
+
+	f, _ := fs.Create("/home/user/myapp.yaml")
+	f.WriteString("foo: bar")
+	f.Close()
+
+	// HCL will have a "lower" priority in the search order
+	fs.Create("/home/user/myapp.hcl")
+
+	finder := locafero.Finder{
+		Paths: []string{"/home/user"},
+		Names: locafero.NameWithExtensions("myapp", viper.SupportedExts...),
+		Type:  locafero.FileTypeFile, // This is important!
+	}
+
+	v := viper.NewWithOptions(viper.WithFinder(finder))
+	v.SetFs(fs)
+	v.ReadInConfig()
+
+	fmt.Println(v.GetString("foo"))
+
+	// Output:
+	// bar
+}
+
+func ExampleFinders() {
+	fs := afero.NewMemMapFs()
+
+	fs.Mkdir("/home/user", 0o777)
+	f, _ := fs.Create("/home/user/myapp.yaml")
+	f.WriteString("foo: bar")
+	f.Close()
+
+	fs.Mkdir("/etc/myapp", 0o777)
+	fs.Create("/etc/myapp/config.yaml")
+
+	// Combine multiple finders to search for files in multiple locations with different criteria
+	finder := viper.Finders(
+		locafero.Finder{
+			Paths: []string{"/home/user"},
+			Names: locafero.NameWithExtensions("myapp", viper.SupportedExts...),
+			Type:  locafero.FileTypeFile, // This is important!
+		},
+		locafero.Finder{
+			Paths: []string{"/etc/myapp"},
+			Names: []string{"config.yaml"}, // Only accept YAML files in the system config directory
+			Type:  locafero.FileTypeFile,   // This is important!
+		},
+	)
+
+	v := viper.NewWithOptions(viper.WithFinder(finder))
+	v.SetFs(fs)
+	v.ReadInConfig()
+
+	fmt.Println(v.GetString("foo"))
+
+	// Output:
+	// bar
+}
diff --git a/finder_test.go b/finder_test.go
new file mode 100644
index 0000000..d57a413
--- /dev/null
+++ b/finder_test.go
@@ -0,0 +1,42 @@
+package viper
+
+import (
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+type finderStub struct {
+	results []string
+}
+
+func (f finderStub) Find(_ afero.Fs) ([]string, error) {
+	return f.results, nil
+}
+
+func TestFinders(t *testing.T) {
+	finder := Finders(
+		finderStub{
+			results: []string{
+				"/home/user/.viper.yaml",
+			},
+		},
+		finderStub{
+			results: []string{
+				"/etc/viper/config.yaml",
+			},
+		},
+	)
+
+	results, err := finder.Find(afero.NewMemMapFs())
+	require.NoError(t, err)
+
+	expected := []string{
+		"/home/user/.viper.yaml",
+		"/etc/viper/config.yaml",
+	}
+
+	assert.Equal(t, expected, results)
+}