diff --git a/commands_display.go b/commands_display.go index 2b536e90b..511a2905c 100644 --- a/commands_display.go +++ b/commands_display.go @@ -165,7 +165,7 @@ func displayResticHelp(output io.Writer, configuration *config.Config, flags com } } - if restic, err := filesearch.FindResticBinary(resticBinary); err == nil { + if restic, err := filesearch.NewFinder().FindResticBinary(resticBinary); err == nil { buf := bytes.Buffer{} cmd := shell.NewCommand(restic, []string{"help", command}) cmd.Stdout = &buf diff --git a/complete.go b/complete.go index 583afd708..c2ac27f10 100644 --- a/complete.go +++ b/complete.go @@ -134,7 +134,7 @@ func (c *Completer) listProfileNames() (list []string) { format = formatFlag.Value.String() } - if file, err := filesearch.FindConfigurationFile(filename); err == nil { + if file, err := filesearch.NewFinder().FindConfigurationFile(filename); err == nil { if conf, err := config.LoadFile(file, format); err == nil { list = append(list, conf.GetProfileNames()...) for name := range conf.GetProfileGroups() { diff --git a/config/config.go b/config/config.go index cfc136417..aa7a92f08 100644 --- a/config/config.go +++ b/config/config.go @@ -101,7 +101,7 @@ func LoadFile(configFile, format string) (config *Config, err error) { // Load includes (if any). var includes []string - if includes, err = filesearch.FindConfigurationIncludes(configFile, config.getIncludes()); err == nil { + if includes, err = filesearch.NewFinder().FindConfigurationIncludes(configFile, config.getIncludes()); err == nil { for _, include := range includes { format := formatFromExtension(include) diff --git a/filesearch/filesearch.go b/filesearch/filesearch.go index ce9d9556b..b4ec9486a 100644 --- a/filesearch/filesearch.go +++ b/filesearch/filesearch.go @@ -1,9 +1,7 @@ package filesearch import ( - "errors" "fmt" - iofs "io/fs" "os" "os/exec" "path/filepath" @@ -12,13 +10,12 @@ import ( "github.com/adrg/xdg" "github.com/creativeprojects/clog" + "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/platform" "github.com/spf13/afero" ) var ( - AppName = "resticprofile" - // configurationExtensions list the possible extensions for the config file configurationExtensions = []string{ "conf", @@ -26,6 +23,7 @@ var ( "toml", "json", "hcl", + "yml", } defaultConfigurationLocationsUnix = []string{ @@ -41,7 +39,7 @@ var ( } addConfigurationLocationsDarwin = []string{ - ".config/" + AppName + "/", + ".config/" + constants.ApplicationName + "/", } defaultConfigurationLocationsWindows = []string{ @@ -69,27 +67,31 @@ var ( } ) -var fs afero.Fs // we could probably change the implementation to use fs.FS instead +type Finder struct { + fs afero.Fs +} -func init() { - fs = afero.NewOsFs() +func NewFinder() Finder { + return Finder{ + fs: afero.NewOsFs(), // external packages will always need files from the OS + } } // FindConfigurationFile returns the path of the configuration file // If the file doesn't have an extension, it will search for all possible extensions -func FindConfigurationFile(configFile string) (string, error) { +func (f Finder) FindConfigurationFile(configFile string) (string, error) { found := "" extension := filepath.Ext(configFile) displayFile := "" if extension != "" { displayFile = fmt.Sprintf("'%s'", configFile) // Search only once through the paths - found = findConfigurationFileWithExtension(configFile) + found = f.findConfigurationFileWithExtension(configFile) } else { displayFile = fmt.Sprintf("'%s' with extensions %s", configFile, strings.Join(configurationExtensions, ", ")) // Search all extensions one by one for _, ext := range configurationExtensions { - found = findConfigurationFileWithExtension(configFile + "." + ext) + found = f.findConfigurationFileWithExtension(configFile + "." + ext) if found != "" { break } @@ -105,9 +107,9 @@ func FindConfigurationFile(configFile string) (string, error) { strings.Join(getSearchConfigurationLocations(), ", ")) } -func findConfigurationFileWithExtension(configFile string) string { +func (f Finder) findConfigurationFileWithExtension(configFile string) string { // simple case: try current folder (or rooted path) - if fileExists(configFile) { + if fileExists(f.fs, configFile) { return configFile } @@ -116,7 +118,7 @@ func findConfigurationFileWithExtension(configFile string) string { for _, configPath := range paths { filename := filepath.Join(configPath, configFile) - if fileExists(filename) { + if fileExists(f.fs, filename) { return filename } } @@ -125,7 +127,7 @@ func findConfigurationFileWithExtension(configFile string) string { } // FindConfigurationIncludes finds includes (glob patterns) relative to the configuration file. -func FindConfigurationIncludes(configFile string, includes []string) ([]string, error) { +func (f Finder) FindConfigurationIncludes(configFile string, includes []string) ([]string, error) { if !filepath.IsAbs(configFile) { var err error if configFile, err = filepath.Abs(configFile); err != nil { @@ -150,10 +152,10 @@ func FindConfigurationIncludes(configFile string, includes []string) ([]string, include = filepath.Join(base, include) } - if fileExists(include) { + if fileExists(f.fs, include) { addFile(include) } else { - if matches, err := afero.Glob(fs, include); err == nil && matches != nil { + if matches, err := afero.Glob(f.fs, include); err == nil && matches != nil { sort.Strings(matches) for _, match := range matches { addFile(match) @@ -170,14 +172,14 @@ func FindConfigurationIncludes(configFile string, includes []string) ([]string, } // FindResticBinary returns the path of restic executable -func FindResticBinary(configLocation string) (string, error) { +func (f Finder) FindResticBinary(configLocation string) (string, error) { if configLocation != "" { // Start by the location from the configuration filename, err := ShellExpand(configLocation) if err != nil { clog.Warning(err) } - if filename != "" && fileExists(filename) { + if filename != "" && fileExists(f.fs, filename) { return filename, nil } clog.Warningf("cannot find or read the restic binary specified in the configuration: %q", configLocation) @@ -187,7 +189,7 @@ func FindResticBinary(configLocation string) (string, error) { for _, configPath := range paths { filename := filepath.Join(configPath, binaryFile) - if fileExists(filename) { + if fileExists(f.fs, filename) { return filename, nil } } @@ -219,9 +221,9 @@ func ShellExpand(filename string) (string, error) { func getSearchConfigurationLocations() []string { home, _ := os.UserHomeDir() - locations := []string{filepath.Join(xdg.ConfigHome, AppName)} + locations := []string{filepath.Join(xdg.ConfigHome, constants.ApplicationName)} for _, configDir := range xdg.ConfigDirs { - locations = append(locations, filepath.Join(configDir, AppName)) + locations = append(locations, filepath.Join(configDir, constants.ApplicationName)) } if platform.IsWindows() { @@ -268,9 +270,9 @@ func getResticBinaryName() string { return resticBinaryUnix } -func fileExists(filename string) bool { - _, err := fs.Stat(filename) - return err == nil || errors.Is(err, iofs.ErrExist) +func fileExists(fs afero.Fs, filename string) bool { + info, err := fs.Stat(filename) + return err == nil && !info.IsDir() } func addRootToRelativePaths(home string, paths []string) []string { diff --git a/filesearch/filesearch_test.go b/filesearch/filesearch_test.go index 54edf7a93..f736b01c7 100644 --- a/filesearch/filesearch_test.go +++ b/filesearch/filesearch_test.go @@ -5,8 +5,8 @@ import ( iofs "io/fs" "os" "os/user" + "path" "path/filepath" - "runtime" "strings" "testing" "time" @@ -18,11 +18,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestMain(t *testing.M) { - fs = afero.NewMemMapFs() - os.Exit(t.Run()) -} - // Quick test to see the default xdg config on the build agents // // Linux: @@ -48,11 +43,10 @@ func TestDefaultConfigDirs(t *testing.T) { } type testLocation struct { - realPath string - realFile string - searchPath string - searchFile string - deletePathAfter bool + realPath string + realFile string + searchPath string + searchFile string } func testLocations(t *testing.T) []testLocation { @@ -101,46 +95,52 @@ func testLocations(t *testing.T) []testLocation { searchFile: "profiles", }, { - realPath: "unittest-config", - realFile: "profiles.spec", - searchPath: "unittest-config", - searchFile: "profiles.spec", - deletePathAfter: true, + realPath: "", + realFile: "profiles.yml", + searchPath: "", + searchFile: "profiles", + }, + { + realPath: "unittest-config", + realFile: "profiles.spec", + searchPath: "unittest-config", + searchFile: "profiles.spec", + }, + { + realPath: "unittest-config", + realFile: "profiles.conf", + searchPath: "unittest-config", + searchFile: "profiles", }, { - realPath: "unittest-config", - realFile: "profiles.conf", - searchPath: "unittest-config", - searchFile: "profiles", - deletePathAfter: true, + realPath: "unittest-config", + realFile: "profiles.toml", + searchPath: "unittest-config", + searchFile: "profiles", }, { - realPath: "unittest-config", - realFile: "profiles.toml", - searchPath: "unittest-config", - searchFile: "profiles", - deletePathAfter: true, + realPath: "unittest-config", + realFile: "profiles.yaml", + searchPath: "unittest-config", + searchFile: "profiles", }, { - realPath: "unittest-config", - realFile: "profiles.yaml", - searchPath: "unittest-config", - searchFile: "profiles", - deletePathAfter: true, + realPath: "unittest-config", + realFile: "profiles.json", + searchPath: "unittest-config", + searchFile: "profiles", }, { - realPath: "unittest-config", - realFile: "profiles.json", - searchPath: "unittest-config", - searchFile: "profiles", - deletePathAfter: true, + realPath: "unittest-config", + realFile: "profiles.hcl", + searchPath: "unittest-config", + searchFile: "profiles", }, { - realPath: "unittest-config", - realFile: "profiles.hcl", - searchPath: "unittest-config", - searchFile: "profiles", - deletePathAfter: true, + realPath: "unittest-config", + realFile: "profiles.yml", + searchPath: "unittest-config", + searchFile: "profiles", }, { realPath: filepath.Join(xdg.ConfigHome, "resticprofile"), @@ -178,6 +178,12 @@ func testLocations(t *testing.T) []testLocation { searchPath: "", searchFile: "profiles", }, + { + realPath: filepath.Join(xdg.ConfigHome, "resticprofile"), + realFile: "profiles.yml", + searchPath: "", + searchFile: "profiles", + }, } // on windows, we allow config file next to the resticprofile executable @@ -196,47 +202,38 @@ func testLocations(t *testing.T) []testLocation { func TestFindConfigurationFile(t *testing.T) { t.Parallel() - // Work from a temporary directory - err := os.Chdir(os.TempDir()) - require.NoError(t, err) - - cwd, err := os.Getwd() - require.NoError(t, err) - t.Log("Working directory:", cwd) - locations := testLocations(t) for _, location := range locations { - var err error - // Install empty config file - if location.realPath != "" { - err = fs.MkdirAll(location.realPath, 0o700) + t.Run(path.Join(location.realPath, location.realFile), func(t *testing.T) { + t.Parallel() + var err error + fs := afero.NewMemMapFs() + finder := Finder{fs: fs} + + // Install empty config file + if location.realPath != "" { + err = fs.MkdirAll(location.realPath, 0o700) + require.NoError(t, err) + } + file, err := fs.Create(filepath.Join(location.realPath, location.realFile)) require.NoError(t, err) - } - file, err := fs.Create(filepath.Join(location.realPath, location.realFile)) - require.NoError(t, err) - file.Close() + require.NoError(t, file.Close()) - // Test - found, err := FindConfigurationFile(filepath.Join(location.searchPath, location.searchFile)) - assert.NoError(t, err) - assert.NotEmpty(t, found) - assert.Equal(t, filepath.Join(location.realPath, location.realFile), found) - - // Clears up the test file - if location.realPath == "" || !location.deletePathAfter { - err = fs.Remove(filepath.Join(location.realPath, location.realFile)) - } else { - err = fs.RemoveAll(location.realPath) - } - require.NoError(t, err) + // Test + found, err := finder.FindConfigurationFile(filepath.Join(location.searchPath, location.searchFile)) + assert.NoError(t, err) + assert.NotEmpty(t, found) + assert.Equal(t, filepath.Join(location.realPath, location.realFile), found) + }) } } func TestCannotFindConfigurationFile(t *testing.T) { t.Parallel() - found, err := FindConfigurationFile("some_config_file") + finder := NewFinder() + found, err := finder.FindConfigurationFile("some_unknown-config_file") assert.Empty(t, found) assert.Error(t, err) } @@ -244,8 +241,33 @@ func TestCannotFindConfigurationFile(t *testing.T) { func TestFindResticBinary(t *testing.T) { t.Parallel() - binary, err := FindResticBinary("some_other_name") + var paths []string + if platform.IsWindows() { + paths = defaultBinaryLocationsWindows + } else { + paths = defaultBinaryLocationsUnix + } + + fs := afero.NewMemMapFs() + file, err := fs.Create(path.Join(paths[len(paths)-1], getResticBinaryName())) + require.NoError(t, err) + require.NoError(t, file.Close()) + + finder := Finder{fs: fs} + binary, err := finder.FindResticBinary("") + + assert.True(t, strings.HasSuffix(binary, getResticBinaryName())) + assert.NoError(t, err) +} + +func TestMayFindResticBinary(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + finder := Finder{fs: fs} + binary, err := finder.FindResticBinary("") if binary != "" { + // not found from fs, but latest resort is to search in the path assert.True(t, strings.HasSuffix(binary, getResticBinaryName())) assert.NoError(t, err) } else { @@ -256,22 +278,22 @@ func TestFindResticBinary(t *testing.T) { func TestFindResticBinaryWithTilde(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { + if platform.IsWindows() { t.Skip("not supported on Windows") return } home, err := os.UserHomeDir() require.NoError(t, err) + fs := afero.NewMemMapFs() + finder := Finder{fs: fs} + tempFile, err := afero.TempFile(fs, home, t.Name()) require.NoError(t, err) tempFile.Close() - defer func() { - _ = fs.Remove(tempFile.Name()) - }() search := filepath.Join("~", filepath.Base(tempFile.Name())) - binary, err := FindResticBinary(search) + binary, err := finder.FindResticBinary(search) require.NoError(t, err) assert.Equalf(t, tempFile.Name(), binary, "cannot find %q", search) } @@ -279,7 +301,7 @@ func TestFindResticBinaryWithTilde(t *testing.T) { func TestShellExpand(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { + if platform.IsWindows() { t.Skip("not supported on Windows") return } @@ -314,6 +336,7 @@ func TestShellExpand(t *testing.T) { func TestFindConfigurationIncludes(t *testing.T) { t.Parallel() + fs := afero.NewMemMapFs() testID := fmt.Sprintf("%x", time.Now().UnixNano()) tempDir := os.TempDir() files := []string{ @@ -325,9 +348,6 @@ func TestFindConfigurationIncludes(t *testing.T) { for _, file := range files { require.NoError(t, afero.WriteFile(fs, file, []byte{}, iofs.ModePerm)) - t.Cleanup(func() { - _ = fs.Remove(file) - }) } testData := []struct { @@ -351,11 +371,13 @@ func TestFindConfigurationIncludes(t *testing.T) { {files[0:1], []string{}}, } + finder := Finder{fs: fs} + for _, test := range testData { t.Run(strings.Join(test.includes, ","), func(t *testing.T) { t.Parallel() - result, err := FindConfigurationIncludes(files[0], test.includes) + result, err := finder.FindConfigurationIncludes(files[0], test.includes) if test.expected == nil { assert.Nil(t, result) assert.NotNil(t, err) diff --git a/main.go b/main.go index f4ca19f36..54cb7deb3 100644 --- a/main.go +++ b/main.go @@ -275,7 +275,7 @@ func banner() { func loadConfig(flags commandLineFlags, silent bool) (cfg *config.Config, global *config.Global, err error) { var configFile string - if configFile, err = filesearch.FindConfigurationFile(flags.config); err == nil { + if configFile, err = filesearch.NewFinder().FindConfigurationFile(flags.config); err == nil { if configFile != flags.config && !silent { clog.Infof("using configuration file: %s", configFile) } @@ -346,7 +346,7 @@ func shouldStopOnBattery(batteryLimit int) bool { } func detectResticBinary(global *config.Global) (string, error) { - resticBinary, err := filesearch.FindResticBinary(global.ResticBinary) + resticBinary, err := filesearch.NewFinder().FindResticBinary(global.ResticBinary) if err != nil { return "", fmt.Errorf("cannot find restic: %w", err) }