Repository: spf13/viper
Pull request #1227 (2021-09-21 09:09:23)

Implement a finder based on io/fs

github-actions[bot]: 👋 Thanks for contributing to Viper! You are awesome! 🎉

A maintainer will take a look at your pull request shortly. 👀

In the meantime: We are working on **Viper v2** and we would love to hear your thoughts about what you like or don't like about Viper, so we can improve or fix those issues.

⏰ If you have a couple minutes, please take some time and share your thoughts: https://forms.gle/R6faU74qPRPAzchZ9

📣 If you've already given us your feedback, you can still help by spreading the news,
either by sharing the above link or telling people about this on Twitter:

https://twitter.com/sagikazarmark/status/1306904078967074816

**Thank you!** ❤️


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5267fe2..389898a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,9 @@ jobs:
     name: Build
     runs-on: ${{ matrix.os }}
     strategy:
+      # Fail fast is disabled because there are Go version specific features and tests
+      # that should be able to fail independently.
+      fail-fast: false
       matrix:
         os: [ubuntu-latest, macos-latest]
         go: ['1.14', '1.15', '1.16', '1.17']
diff --git a/fs.go b/fs.go
new file mode 100644
index 0000000..70631f8
--- /dev/null
+++ b/fs.go
@@ -0,0 +1,65 @@
+//go:build go1.16
+// +build go1.16
+
+package viper
+
+import (
+	"errors"
+	"io/fs"
+	"path/filepath"
+)
+
+type finder struct {
+	paths      []string
+	fileNames  []string
+	extensions []string
+
+	withoutExtension bool
+}
+
+func (f finder) Find(fsys fs.FS) (string, error) {
+	for _, path := range f.paths {
+		for _, fileName := range f.fileNames {
+			for _, extension := range f.extensions {
+				filePath := filepath.Join(path, fileName+"."+extension)
+
+				ok, err := fileExists(fsys, filePath)
+				if err != nil {
+					return "", err
+				}
+
+				if ok {
+					return filePath, nil
+				}
+			}
+
+			if f.withoutExtension {
+				filePath := filepath.Join(path, fileName)
+
+				ok, err := fileExists(fsys, filePath)
+				if err != nil {
+					return "", err
+				}
+
+				if ok {
+					return filePath, nil
+				}
+			}
+		}
+	}
+
+	return "", nil
+}
+
+func fileExists(fsys fs.FS, filePath string) (bool, error) {
+	fileInfo, err := fs.Stat(fsys, filePath)
+	if err == nil {
+		return !fileInfo.IsDir(), nil
+	}
+
+	if errors.Is(err, fs.ErrNotExist) {
+		return false, nil
+	}
+
+	return false, err
+}
diff --git a/util.go b/util.go
index 09d051a..742f997 100644
--- a/util.go
+++ b/util.go
@@ -18,7 +18,6 @@ import (
 	"strings"
 	"unicode"
 
-	"github.com/spf13/afero"
 	"github.com/spf13/cast"
 	jww "github.com/spf13/jwalterweatherman"
 )
@@ -111,18 +110,6 @@ func absPathify(inPath string) string {
 	return ""
 }
 
-// Check if file Exists
-func exists(fs afero.Fs, path string) (bool, error) {
-	stat, err := fs.Stat(path)
-	if err == nil {
-		return !stat.IsDir(), nil
-	}
-	if os.IsNotExist(err) {
-		return false, nil
-	}
-	return false, err
-}
-
 func stringInSlice(a string, list []string) bool {
 	for _, b := range list {
 		if b == a {
diff --git a/viper.go b/viper.go
index 9e2e353..6c456a9 100644
--- a/viper.go
+++ b/viper.go
@@ -2108,39 +2108,6 @@ func (v *Viper) getConfigFile() (string, error) {
 	return v.configFile, nil
 }
 
-func (v *Viper) searchInPath(in string) (filename string) {
-	jww.DEBUG.Println("Searching for config in ", in)
-	for _, ext := range SupportedExts {
-		jww.DEBUG.Println("Checking for", filepath.Join(in, v.configName+"."+ext))
-		if b, _ := exists(v.fs, filepath.Join(in, v.configName+"."+ext)); b {
-			jww.DEBUG.Println("Found: ", filepath.Join(in, v.configName+"."+ext))
-			return filepath.Join(in, v.configName+"."+ext)
-		}
-	}
-
-	if v.configType != "" {
-		if b, _ := exists(v.fs, filepath.Join(in, v.configName)); b {
-			return filepath.Join(in, v.configName)
-		}
-	}
-
-	return ""
-}
-
-// Search all configPaths for any config file.
-// Returns the first path that exists (and is a config file).
-func (v *Viper) findConfigFile() (string, error) {
-	jww.INFO.Println("Searching for config in ", v.configPaths)
-
-	for _, cp := range v.configPaths {
-		file := v.searchInPath(cp)
-		if file != "" {
-			return file, nil
-		}
-	}
-	return "", ConfigFileNotFoundError{v.configName, fmt.Sprintf("%s", v.configPaths)}
-}
-
 // Debug prints all configuration registries for debugging
 // purposes.
 func Debug() { v.Debug() }
diff --git a/viper_go1_15.go b/viper_go1_15.go
new file mode 100644
index 0000000..487e0c8
--- /dev/null
+++ b/viper_go1_15.go
@@ -0,0 +1,58 @@
+//go:build !go1.16
+// +build !go1.16
+
+package viper
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/spf13/afero"
+	jww "github.com/spf13/jwalterweatherman"
+)
+
+// Search all configPaths for any config file.
+// Returns the first path that exists (and is a config file).
+func (v *Viper) findConfigFile() (string, error) {
+	jww.INFO.Println("Searching for config in ", v.configPaths)
+
+	for _, cp := range v.configPaths {
+		file := v.searchInPath(cp)
+		if file != "" {
+			return file, nil
+		}
+	}
+	return "", ConfigFileNotFoundError{v.configName, fmt.Sprintf("%s", v.configPaths)}
+}
+
+func (v *Viper) searchInPath(in string) (filename string) {
+	jww.DEBUG.Println("Searching for config in ", in)
+	for _, ext := range SupportedExts {
+		jww.DEBUG.Println("Checking for", filepath.Join(in, v.configName+"."+ext))
+		if b, _ := exists(v.fs, filepath.Join(in, v.configName+"."+ext)); b {
+			jww.DEBUG.Println("Found: ", filepath.Join(in, v.configName+"."+ext))
+			return filepath.Join(in, v.configName+"."+ext)
+		}
+	}
+
+	if v.configType != "" {
+		if b, _ := exists(v.fs, filepath.Join(in, v.configName)); b {
+			return filepath.Join(in, v.configName)
+		}
+	}
+
+	return ""
+}
+
+// Check if file Exists
+func exists(fs afero.Fs, path string) (bool, error) {
+	stat, err := fs.Stat(path)
+	if err == nil {
+		return !stat.IsDir(), nil
+	}
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	return false, err
+}
diff --git a/viper_go1_16.go b/viper_go1_16.go
new file mode 100644
index 0000000..eb313fa
--- /dev/null
+++ b/viper_go1_16.go
@@ -0,0 +1,32 @@
+//go:build go1.16
+// +build go1.16
+
+package viper
+
+import (
+	"fmt"
+
+	"github.com/spf13/afero"
+)
+
+// Search all configPaths for any config file.
+// Returns the first path that exists (and is a config file).
+func (v *Viper) findConfigFile() (string, error) {
+	finder := finder{
+		paths:            v.configPaths,
+		fileNames:        []string{v.configName},
+		extensions:       SupportedExts,
+		withoutExtension: v.configType != "",
+	}
+
+	file, err := finder.Find(afero.NewIOFS(v.fs))
+	if err != nil {
+		return "", err
+	}
+
+	if file == "" {
+		return "", ConfigFileNotFoundError{v.configName, fmt.Sprintf("%s", v.configPaths)}
+	}
+
+	return file, nil
+}


Test patch:
diff --git a/fs_test.go b/fs_test.go
new file mode 100644
index 0000000..d811f45
--- /dev/null
+++ b/fs_test.go
@@ -0,0 +1,100 @@
+//go:build go1.16
+// +build go1.16
+
+package viper
+
+import (
+	"io/fs"
+	"testing"
+	"testing/fstest"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFinder(t *testing.T) {
+	t.Parallel()
+
+	fsys := fstest.MapFS{
+		"home/user/.config":            &fstest.MapFile{},
+		"home/user/config.json":        &fstest.MapFile{},
+		"home/user/config.yaml":        &fstest.MapFile{},
+		"home/user/data.json":          &fstest.MapFile{},
+		"etc/config/.config":           &fstest.MapFile{},
+		"etc/config/a_random_file.txt": &fstest.MapFile{},
+		"etc/config/config.json":       &fstest.MapFile{},
+		"etc/config/config.yaml":       &fstest.MapFile{},
+		"etc/config/config.xml":        &fstest.MapFile{},
+	}
+
+	testCases := []struct {
+		name   string
+		fsys   func() fs.FS
+		finder finder
+		result string
+	}{
+		{
+			name: "find file",
+			fsys: func() fs.FS { return fsys },
+			finder: finder{
+				paths:      []string{"etc/config"},
+				fileNames:  []string{"config"},
+				extensions: []string{"json"},
+			},
+			result: "etc/config/config.json",
+		},
+		{
+			name: "file not found",
+			fsys: func() fs.FS { return fsys },
+			finder: finder{
+				paths:      []string{"var/config"},
+				fileNames:  []string{"config"},
+				extensions: []string{"json"},
+			},
+			result: "",
+		},
+		{
+			name:   "empty search params",
+			fsys:   func() fs.FS { return fsys },
+			finder: finder{},
+			result: "",
+		},
+		{
+			name: "precedence",
+			fsys: func() fs.FS { return fsys },
+			finder: finder{
+				paths:      []string{"var/config", "home/user", "etc/config"},
+				fileNames:  []string{"aconfig", "config"},
+				extensions: []string{"zml", "xml", "json"},
+			},
+			result: "home/user/config.json",
+		},
+		{
+			name: "without extension",
+			fsys: func() fs.FS { return fsys },
+			finder: finder{
+				paths:      []string{"var/config", "home/user", "etc/config"},
+				fileNames:  []string{".config"},
+				extensions: []string{"zml", "xml", "json"},
+
+				withoutExtension: true,
+			},
+			result: "home/user/.config",
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			fsys := testCase.fsys()
+
+			result, err := testCase.finder.Find(fsys)
+			require.NoError(t, err)
+
+			assert.Equal(t, testCase.result, result)
+		})
+	}
+}
diff --git a/viper_test.go b/viper_test.go
index c371c07..2ad25ba 100644
--- a/viper_test.go
+++ b/viper_test.go
@@ -301,65 +301,192 @@ func (s *stringValue) String() string {
 	return string(*s)
 }
 
-func TestBasics(t *testing.T) {
-	SetConfigFile("/tmp/config.yaml")
-	filename, err := v.getConfigFile()
-	assert.Equal(t, "/tmp/config.yaml", filename)
-	assert.NoError(t, err)
-}
+func TestGetConfigFile(t *testing.T) {
+	t.Run("config file set", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
 
-func TestSearchInPath_WithoutConfigTypeSet(t *testing.T) {
-	filename := ".dotfilenoext"
-	path := "/tmp"
-	file := filepath.Join(path, filename)
-	SetConfigName(filename)
-	AddConfigPath(path)
-	_, createErr := v.fs.Create(file)
-	defer func() {
-		_ = v.fs.Remove(file)
-	}()
-	assert.NoError(t, createErr)
-	_, err := v.getConfigFile()
-	// unless config type is set, files without extension
-	// are not considered
-	assert.Error(t, err)
-}
-
-func TestSearchInPath(t *testing.T) {
-	filename := ".dotfilenoext"
-	path := "/tmp"
-	file := filepath.Join(path, filename)
-	SetConfigName(filename)
-	SetConfigType("yaml")
-	AddConfigPath(path)
-	_, createErr := v.fs.Create(file)
-	defer func() {
-		_ = v.fs.Remove(file)
-	}()
-	assert.NoError(t, createErr)
-	filename, err := v.getConfigFile()
-	assert.Equal(t, file, filename)
-	assert.NoError(t, err)
+		err := fs.Mkdir("/etc/viper", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/etc/viper/config.yaml")
+		require.NoError(t, err)
+
+		v := New()
+
+		v.SetFs(fs)
+		v.AddConfigPath("/etc/viper")
+		v.SetConfigFile("/etc/viper/config.json")
+
+		filename, err := v.getConfigFile()
+		assert.Equal(t, "/etc/viper/config.json", filename)
+		assert.NoError(t, err)
+	})
+
+	t.Run("find file", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+
+		err := fs.Mkdir("/etc/viper", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/etc/viper/config.yaml")
+		require.NoError(t, err)
+
+		v := New()
+
+		v.SetFs(fs)
+		v.AddConfigPath("/etc/viper")
+
+		filename, err := v.getConfigFile()
+		assert.Equal(t, "/etc/viper/config.yaml", filename)
+		assert.NoError(t, err)
+	})
+
+	t.Run("find files only", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+
+		err := fs.Mkdir("/etc/config", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/etc/config/config.yaml")
+		require.NoError(t, err)
+
+		v := New()
+
+		v.SetFs(fs)
+		v.AddConfigPath("/etc")
+		v.AddConfigPath("/etc/config")
+
+		filename, err := v.getConfigFile()
+		assert.Equal(t, "/etc/config/config.yaml", filename)
+		assert.NoError(t, err)
+	})
+
+	t.Run("precedence", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+
+		err := fs.Mkdir("/home/viper", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/home/viper/config.zml")
+		require.NoError(t, err)
+
+		err = fs.Mkdir("/etc/viper", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/etc/viper/config.bml")
+		require.NoError(t, err)
+
+		err = fs.Mkdir("/var/viper", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/var/viper/config.yaml")
+		require.NoError(t, err)
+
+		v := New()
+
+		v.SetFs(fs)
+		v.AddConfigPath("/home/viper")
+		v.AddConfigPath("/etc/viper")
+		v.AddConfigPath("/var/viper")
+
+		filename, err := v.getConfigFile()
+		assert.Equal(t, "/var/viper/config.yaml", filename)
+		assert.NoError(t, err)
+	})
+
+	t.Run("without extension", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+
+		err := fs.Mkdir("/etc/viper", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/etc/viper/.dotfilenoext")
+		require.NoError(t, err)
+
+		v := New()
+
+		v.SetFs(fs)
+		v.AddConfigPath("/etc/viper")
+		v.SetConfigName(".dotfilenoext")
+		v.SetConfigType("yaml")
+
+		filename, err := v.getConfigFile()
+		assert.Equal(t, "/etc/viper/.dotfilenoext", filename)
+		assert.NoError(t, err)
+	})
+
+	t.Run("without extension and config type", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+
+		err := fs.Mkdir("/etc/viper", 0777)
+		require.NoError(t, err)
+
+		_, err = fs.Create("/etc/viper/.dotfilenoext")
+		require.NoError(t, err)
+
+		v := New()
+
+		v.SetFs(fs)
+		v.AddConfigPath("/etc/viper")
+		v.SetConfigName(".dotfilenoext")
+
+		_, err = v.getConfigFile()
+		// unless config type is set, files without extension
+		// are not considered
+		assert.Error(t, err)
+	})
 }
 
-func TestSearchInPath_FilesOnly(t *testing.T) {
-	fs := afero.NewMemMapFs()
+func TestReadInConfig(t *testing.T) {
+	t.Run("config file set", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
 
-	err := fs.Mkdir("/tmp/config", 0777)
-	require.NoError(t, err)
+		err := fs.Mkdir("/etc/viper", 0777)
+		require.NoError(t, err)
 
-	_, err = fs.Create("/tmp/config/config.yaml")
-	require.NoError(t, err)
+		file, err := fs.Create("/etc/viper/config.yaml")
+		require.NoError(t, err)
 
-	v := New()
+		_, err = file.Write([]byte(`key: value`))
+		require.NoError(t, err)
 
-	v.SetFs(fs)
-	v.AddConfigPath("/tmp")
-	v.AddConfigPath("/tmp/config")
+		file.Close()
 
-	filename, err := v.getConfigFile()
-	assert.Equal(t, "/tmp/config/config.yaml", filename)
-	assert.NoError(t, err)
+		v := New()
+
+		v.SetFs(fs)
+		v.SetConfigFile("/etc/viper/config.yaml")
+
+		err = v.ReadInConfig()
+		require.NoError(t, err)
+
+		assert.Equal(t, "value", v.Get("key"))
+	})
+
+	t.Run("find file", func(t *testing.T) {
+		fs := afero.NewMemMapFs()
+
+		err := fs.Mkdir("/etc/viper", 0777)
+		require.NoError(t, err)
+
+		file, err := fs.Create("/etc/viper/config.yaml")
+		require.NoError(t, err)
+
+		_, err = file.Write([]byte(`key: value`))
+		require.NoError(t, err)
+
+		file.Close()
+
+		v := New()
+
+		v.SetFs(fs)
+		v.AddConfigPath("/etc/viper")
+
+		err = v.ReadInConfig()
+		require.NoError(t, err)
+
+		assert.Equal(t, "value", v.Get("key"))
+	})
 }
 
 func TestDefault(t *testing.T) {