Repository: gphotosuploader/gphotos-uploader-cli
Pull request #113 (2019-08-24 05:48:29)

Add --config flag to allow to change configuration dir
With `--config /my/new/configuration_dir` you can specify where configuration is going to be kept.

Closes: #104 


Patch:
diff --git a/README.md b/README.md
index 2390c9c..56fc768 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ First initialize the config file using this command:
 $ gphotos-uploader-cli init
 ```
 
-then modify it at `~/.config/gphotos-uploader-cli/config.hjson` to specify your configuration.
+by default configuration folder is `~/.config/gphotos-uploader-cli` but you can specify your own folder using `--config /my/config/dir`. Configuration is kept in the `config.hjson` file inside this folder.
 
 You can review the [documentation](.docs/configuration.md) to specify the folder to upload, add more Google Accounts and tune your configuration.
 
diff --git a/cmd/init.go b/cmd/init.go
index 00820c0..c49a29c 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -14,14 +14,19 @@ var initCmd = &cobra.Command{
 	Use:   "init",
 	Short: "Init the configuration file",
 	Run: func(cmd *cobra.Command, args []string) {
-		err := config.InitConfigFile(defaultCfgFile)
+		force, err := cmd.Flags().GetBool("force")
 		if err != nil {
-			log.Fatalf("Failed to create the init config file: %v", err)
+			force = false
 		}
-		fmt.Printf("Configuration file has been created.\nEdit it by running:\n    nano %s\n", defaultCfgFile)
+		err = config.InitConfig(cfgDir, force)
+		if err != nil {
+			log.Fatal(err)
+		}
+		fmt.Printf("Configuration file has been created.\nEdit it by running:\n    nano %s/config.hjson\n", cfgDir)
 	},
 }
 
 func init() {
+	initCmd.Flags().BoolP("force", "f", false, "Remove existing configuration folder (if exists)")
 	rootCmd.AddCommand(initCmd)
 }
diff --git a/cmd/root.go b/cmd/root.go
index 4dc744a..ae3b0f1 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -18,9 +18,9 @@ import (
 	"github.com/gphotosuploader/gphotos-uploader-cli/upload"
 )
 
-const defaultCfgFile = "~/.config/gphotos-uploader-cli/config.hjson"
+const defaultCfgDir = "~/.config/gphotos-uploader-cli"
 
-var cfgFile string
+var cfgDir string
 
 // rootCmd represents the base command when called without any subcommands
 var rootCmd = &cobra.Command{
@@ -44,21 +44,21 @@ func Execute() {
 }
 
 func init() {
-	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", defaultCfgFile, "set config file")
+	rootCmd.PersistentFlags().StringVar(&cfgDir, "config", defaultCfgDir, "set config folder")
 }
 
 func startUploader(cmd *cobra.Command, args []string) {
 	var cfg *config.Config
 
-	cfg, err := config.LoadConfigFile(cfgFile)
+	cfg, err := config.LoadConfig(cfgDir)
 	if err != nil {
-		log.Fatalf("Unable to read configuration file (%s).\nPlease review your configuration or execute 'gphotos-uploader-cli init' to create a new one.", cfgFile)
+		log.Fatalf("Unable to read configuration from '%s'.\nPlease review your configuration or execute 'gphotos-uploader-cli init' to create a new one.", cfgDir)
 	}
 
 	// load completedUploads DB
-	db, err := leveldb.OpenFile(config.GetUploadsDBPath(), nil)
+	db, err := leveldb.OpenFile(cfg.TrackingDBDir(), nil)
 	if err != nil {
-		log.Fatalf("Error opening db: path=%s, err=%v", config.GetUploadsDBPath(), err)
+		log.Fatalf("Error opening db: path=%s, err=%v", cfg.TrackingDBDir(), err)
 	}
 	defer func() {
 		if err := db.Close(); err != nil {
@@ -68,7 +68,7 @@ func startUploader(cmd *cobra.Command, args []string) {
 	fileTracker := completeduploads.NewService(completeduploads.NewLevelDBRepository(db))
 
 	// token manager service to be used as secrets backend
-	kr, err := tokenstore.NewKeyringRepository(cfg.SecretsBackendType, nil)
+	kr, err := tokenstore.NewKeyringRepository(cfg.SecretsBackendType, nil, cfg.KeyringDir())
 	if err != nil {
 		log.Fatalf("Unable to use the token repository: %v", err)
 	}
diff --git a/config/config.go b/config/config.go
index 3e35a2c..cf54b8a 100644
--- a/config/config.go
+++ b/config/config.go
@@ -5,49 +5,14 @@ import (
 	"io/ioutil"
 	"log"
 	"os"
-	"path/filepath"
-
-	"github.com/nmrshll/go-cp"
-	"golang.org/x/oauth2"
+	"path"
 
 	"github.com/client9/xson/hjson"
-	gphotos "github.com/gphotosuploader/google-photos-api-client-go/lib-gphotos"
+	"github.com/gphotosuploader/gphotos-uploader-cli/utils/filesystem"
 )
 
-// APIAppCredentials represents Google Photos API credentials for OAuth
-type APIAppCredentials struct {
-	ClientID     string
-	ClientSecret string
-}
-
-// FolderUploadJob represents configuration for a folder to be uploaded
-type FolderUploadJob struct {
-	Account           string
-	SourceFolder      string
-	MakeAlbums        MakeAlbums
-	DeleteAfterUpload bool
-	UploadVideos      bool
-	IncludePatterns   []string
-	ExcludePatterns   []string
-}
-
-// MakeAlbums represents configuration about how to create Albums in Google Photos
-type MakeAlbums struct {
-	Enabled bool
-	Use     string
-}
-
-// Config represents this application configuration
-type Config struct {
-	ConfigFile         string
-	Verbose            bool
-	SecretsBackendType string
-	APIAppCredentials  *APIAppCredentials
-	Jobs               []FolderUploadJob
-}
-
-// defaultConfig returns an example Config object
-func defaultConfig() Config {
+// defaultSettings() returns a *Config with the default settings of the application.
+func defaultSettings() *Config {
 	var c Config
 	c.SecretsBackendType = "auto"
 	c.APIAppCredentials = &APIAppCredentials{
@@ -66,11 +31,35 @@ func defaultConfig() Config {
 		UploadVideos:      true,
 	}
 	c.Jobs = append(c.Jobs, job)
-	return c
+	return &c
+}
+
+// NewConfig returns a *Config with the default settings of the application.
+func NewConfig(dir string) *Config {
+	cfg := defaultSettings()
+	cfg.ConfigPath = filesystem.AbsolutePath(dir)
+
+	return cfg
+}
+
+// TrackingDBDir returns the path of the folder where completed uploads are tracked.
+func (c *Config) TrackingDBDir() string {
+	return path.Join(c.ConfigPath, "uploads.db")
+}
+
+// ConfigFile return the path of the configuration file.
+func (c *Config) ConfigFile() string {
+	return path.Join(c.ConfigPath, "config.hjson")
+}
+
+// KeyringDir returns the path of the folder where keyring will be stored.
+// This is only used if 'SecretsBackendType=file'
+func (c *Config) KeyringDir() string {
+	return c.ConfigPath
 }
 
 // String returns a string representation of the Config object
-func (c Config) String() string {
+func (c *Config) String() string {
 	configTemplate := `
 {
   SecretsBackendType: %s,
@@ -105,67 +94,51 @@ func (c Config) String() string {
 		c.Jobs[0].UploadVideos)
 }
 
-// OAuthConfig creates and returns a new oauth Config based on API app credentials found in the uploader's config file
-func OAuthConfig(uploaderConfigAPICredentials *APIAppCredentials) *oauth2.Config {
-	if uploaderConfigAPICredentials == nil {
-		log.Fatalf("APIAppCredentials can't be nil")
-	}
-	return gphotos.NewOAuthConfig(gphotos.APIAppCredentials(*uploaderConfigAPICredentials))
-}
-
-// GetUploadsDBPath returns the absolute path of uploads DB file
-func GetUploadsDBPath() string {
-	const UploadDBPath = "~/.config/gphotos-uploader-cli/uploads.db"
-
-	dbPathAbsolute, err := cp.AbsolutePath(UploadDBPath)
-	if err != nil {
-		log.Fatal(err) // TODO: should return an error instead a log.Fatal
-	}
-	return dbPathAbsolute
-}
-
-// LoadConfigFile reads HJSON file (absolute path) and decodes its configuration
-func LoadConfigFile(p string) (*Config, error) {
-	path, err := cp.AbsolutePath(p)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get absolute path: %s", p)
-	}
+// LoadConfig reads configuration from the specified directory.
+// It reads a HJSON file (given by config.ConfigFile() func) and decodes it.
+func LoadConfig(dir string) (*Config, error) {
+	cfg := NewConfig(dir)
 
-	data, err := ioutil.ReadFile(path)
+	data, err := ioutil.ReadFile(cfg.ConfigFile())
 	if err != nil {
-		return nil, fmt.Errorf("failed to read configuration from file %s, %v", path, err)
+		return nil, fmt.Errorf("failed to read configuration: file=%s, err=%v", cfg.ConfigFile(), err)
 	}
 
-	config := defaultConfig()
-	config.ConfigFile = path
-
-	if err := hjson.Unmarshal(data, &config); err != nil {
-		return nil, fmt.Errorf("failed to decode configuration data: %v", err)
+	if err := hjson.Unmarshal(data, &cfg); err != nil {
+		return nil, fmt.Errorf("failed to decode configuration data: err=%v", err)
 	}
 
-	return &config, nil
+	return cfg, nil
 }
 
-// InitConfigFile creates an example config file if it doesn't already exist
-func InitConfigFile(p string) error {
-	path, err := cp.AbsolutePath(p)
-	if err != nil {
-		return fmt.Errorf("failed to get absolute path: %s", p)
+// InitConfig creates an example config file if it doesn't already exist.
+// If 'force' is set then we are going to remove config dir before creating it.
+func InitConfig(dir string, force bool) error {
+	cfg := NewConfig(dir)
+
+	// if force, we should remove everything to start from the scratch.
+	if force {
+		err := os.RemoveAll(dir)
+		if err != nil {
+			return err
+		}
 	}
 
-	dirname := filepath.Dir(path)
-	if _, err := os.Stat(dirname); os.IsNotExist(err) {
-		err := os.MkdirAll(dirname, 0755)
+	if _, err := os.Stat(cfg.ConfigPath); !os.IsNotExist(err) {
+		// directory already exist and forced was not set
+		return fmt.Errorf("config directory already exists, use '--force' to overwrite: path=%s", cfg.ConfigPath)
+	} else {
+		err := os.MkdirAll(cfg.ConfigPath, 0755)
 		if err != nil {
-			return fmt.Errorf("failed to create config parent directory %s: %v", dirname, err)
+			return fmt.Errorf("failed to create config directory: path=%s, err=%v", cfg.ConfigPath, err)
 		}
 	}
 
-	fh, err := os.Open(path)
+	fh, err := os.Open(cfg.ConfigFile())
 	if err != nil {
-		fh, err = os.Create(path)
+		fh, err = os.Create(cfg.ConfigFile())
 		if err != nil {
-			return fmt.Errorf("failed to create config file %s: %v", path, err)
+			return fmt.Errorf("failed to create config: file=%s, err=%v", cfg.ConfigFile(), err)
 		}
 	}
 	defer func() {
@@ -174,9 +147,9 @@ func InitConfigFile(p string) error {
 		}
 	}()
 
-	_, err = fh.WriteString(defaultConfig().String())
+	_, err = fh.WriteString(cfg.String())
 	if err != nil {
-		return fmt.Errorf("failed to write in config file %s: %v", path, err)
+		return fmt.Errorf("failed to write configuration: file=%s, err=%v", cfg.ConfigFile(), err)
 	}
 
 	return fh.Sync()
diff --git a/config/types.go b/config/types.go
new file mode 100644
index 0000000..c505b05
--- /dev/null
+++ b/config/types.go
@@ -0,0 +1,32 @@
+package config
+
+// Config represents the application settings.
+type Config struct {
+	ConfigPath         string
+	SecretsBackendType string
+	APIAppCredentials  *APIAppCredentials
+	Jobs               []FolderUploadJob
+}
+
+// APIAppCredentials represents Google Photos API credentials for OAuth
+type APIAppCredentials struct {
+	ClientID     string
+	ClientSecret string
+}
+
+// FolderUploadJob represents configuration for a folder to be uploaded
+type FolderUploadJob struct {
+	Account           string
+	SourceFolder      string
+	MakeAlbums        MakeAlbums
+	DeleteAfterUpload bool
+	UploadVideos      bool
+	IncludePatterns   []string
+	ExcludePatterns   []string
+}
+
+// MakeAlbums represents configuration about how to create Albums in Google Photos
+type MakeAlbums struct {
+	Enabled bool
+	Use     string
+}
diff --git a/datastore/tokenstore/repository_keyring.go b/datastore/tokenstore/repository_keyring.go
index c93b9e2..1a2cd2a 100644
--- a/datastore/tokenstore/repository_keyring.go
+++ b/datastore/tokenstore/repository_keyring.go
@@ -30,8 +30,8 @@ type KeyringRepository struct {
 //	WinCredBackend       BackendType = "wincred"
 //	FileBackend          BackendType = "file"
 //	PassBackend          BackendType = "pass"
-func NewKeyringRepository(backend string, promptFunc *keyring.PromptFunc) (*KeyringRepository, error) {
-	keyringConfig := defaultConfig()
+func NewKeyringRepository(backend string, promptFunc *keyring.PromptFunc, keyringDir string) (*KeyringRepository, error) {
+	keyringConfig := defaultConfig(keyringDir)
 	if backend != "" && backend != "auto" {
 		keyringConfig.AllowedBackends = append(keyringConfig.AllowedBackends, keyring.BackendType(backend))
 	}
@@ -45,7 +45,7 @@ func NewKeyringRepository(backend string, promptFunc *keyring.PromptFunc) (*Keyr
 	return &KeyringRepository{kr}, nil
 }
 
-func defaultConfig() keyring.Config {
+func defaultConfig(keyringDir string) keyring.Config {
 	return keyring.Config{
 		AllowedBackends:                nil,
 		ServiceName:                    serviceName,
@@ -55,7 +55,7 @@ func defaultConfig() keyring.Config {
 		KeychainAccessibleWhenUnlocked: false,
 		KeychainPasswordFunc:           nil,
 		FilePasswordFunc:               terminalPrompt,
-		FileDir:                        "~/.config/gphotos-uploader-cli",
+		FileDir:                        keyringDir,
 		KWalletAppID:                   "",
 		KWalletFolder:                  "",
 		LibSecretCollectionName:        "",
diff --git a/filetypes/file.go b/filetypes/file.go
deleted file mode 100644
index 14d880d..0000000
--- a/filetypes/file.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package filetypes
-
-import (
-	"io"
-	"os"
-
-	"github.com/h2non/filetype"
-	filematchers "github.com/h2non/filetype/matchers"
-	"github.com/juju/errors"
-	"github.com/pierrec/xxHash/xxHash32"
-
-	"github.com/gphotosuploader/gphotos-uploader-cli/utils/filesystem"
-)
-
-// IsImage asserts file at filePath is an image
-func IsImage(filePath string) bool {
-	buf, err := filesystem.BufferHeaderFromFile(filePath, 100)
-	if err != nil {
-		return false
-	}
-
-	kind, _ := filetype.Image(buf)
-
-	return kind != filetype.Unknown && kind != filematchers.TypePsd && kind != filematchers.TypeTiff && kind != filematchers.TypeCR2
-}
-
-// IsVideo asserts file at filePath is a video file
-func IsVideo(filePath string) bool {
-	buf, err := filesystem.BufferHeaderFromFile(filePath, 100)
-	if err != nil {
-		return false
-	}
-
-	return filetype.IsVideo(buf)
-}
-
-// IsGif asserts file at filePath is a GIF image
-func IsGif(filePath string) bool {
-	buf, err := filesystem.BufferHeaderFromFile(filePath, 100)
-	if err != nil {
-		return false
-	}
-
-	return filetype.IsMIME(buf, "image/gif")
-}
-
-// IsMedia asserts file at filePath is an image or video or gif
-func IsMedia(filePath string) bool {
-	return IsImage(filePath) || IsVideo(filePath) || IsGif(filePath)
-}
-
-// fileHash is a classic hash for any filetype
-// it differs when one byte of the files differ
-func fileHash(filePath string) (uint32, error) {
-	inputFile, err := os.Open(filePath)
-	if err != nil {
-		return 0, err
-	}
-	defer inputFile.Close()
-
-	hasher := xxHash32.New(0xCAFE) // type hash.Hash32
-	defer hasher.Reset()
-
-	_, err = io.Copy(hasher, inputFile)
-	if err != nil {
-		return 0, err
-	}
-
-	return hasher.Sum32(), nil
-}
-
-// TypedMedia has a method to check the file was correctly uploaded
-type TypedMedia interface {
-	IsCorrectlyUploaded(uploadedFileURL, localFilePath string) (bool, error)
-}
-
-// NewTypedMedia detects if image, gif or video and returns the appropriate TypedMedia
-func NewTypedMedia(filePath string) (TypedMedia, error) {
-	// process only media
-	if !IsMedia(filePath) {
-		return nil, errors.Errorf("failed creating typedMedia: not a media file: %s", filePath)
-	}
-
-	// if image detected
-	if IsImage(filePath) && !IsGif(filePath) && !IsVideo(filePath) {
-		return &ImageTypedMedia{}, nil
-	}
-
-	// if gif detected
-	if IsGif(filePath) && IsImage(filePath) && !IsVideo(filePath) {
-		return &GifTypedMedia{}, nil
-	}
-
-	// if video detected
-	if IsVideo(filePath) && !IsImage(filePath) && !IsGif(filePath) {
-		return &VideoTypedMedia{}, nil
-	}
-
-	return nil, errors.Errorf("failed creating TypedMedia from file: %s", filePath)
-}
diff --git a/filetypes/gif.go b/filetypes/gif.go
deleted file mode 100644
index 7a96827..0000000
--- a/filetypes/gif.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package filetypes
-
-import (
-	"fmt"
-
-	"github.com/juju/errors"
-)
-
-// isSameGifs checks if two gifs (local and uploaded) are exactly the same
-func isSameGifs(upGifPath, localGifPath string) bool {
-	upHash, err := fileHash(upGifPath)
-	if err != nil {
-		return false
-	}
-	localHash, err := fileHash(localGifPath)
-	if err != nil {
-		return false
-	}
-
-	return upHash == localHash
-}
-
-// GifTypedMedia implements TypedMedia for GIF files
-type GifTypedMedia struct{}
-
-// IsCorrectlyUploaded checks that the gif that was uploaded is the same as the local one, before deleting the local one
-func (gm *GifTypedMedia) IsCorrectlyUploaded(uploadedFileURL, localFilePath string) (bool, error) {
-	if !IsGif(localFilePath) {
-		return false, fmt.Errorf("%s is not a gif. Not deleting local file", localFilePath)
-	}
-
-	// compare uploaded image and local one
-	if isSameGifs(uploadedFileURL, localFilePath) {
-		return true, nil
-	}
-
-	return false, errors.New("gif was not uploaded correctly. Not deleting local file")
-}
diff --git a/filetypes/image.go b/filetypes/image.go
deleted file mode 100644
index 1825671..0000000
--- a/filetypes/image.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package filetypes
-
-import (
-	"fmt"
-	imageLib "image"
-
-	// register decoders for jpeg and png
-	_ "image/gif"
-	_ "image/jpeg"
-	_ "image/png"
-
-	"net/http"
-	"os"
-
-	"github.com/Nr90/imgsim"
-	"github.com/juju/errors"
-	"github.com/steakknife/hamming"
-)
-
-func imageFromPath(filePath string) (imageLib.Image, error) {
-	reader, err := os.Open(filePath)
-	if err != nil {
-		return nil, err
-	}
-	defer reader.Close()
-
-	img, _, err := imageLib.Decode(reader)
-	if err != nil {
-		return nil, err
-	}
-
-	return img, nil
-}
-
-func imageFromURL(URL string) (imageLib.Image, error) {
-	res, err := http.Get(URL)
-	if err != nil {
-		return nil, err
-	}
-	defer res.Body.Close()
-	if res.StatusCode != 200 {
-		return nil, fmt.Errorf("expected http status 200, got %d", res.StatusCode)
-	}
-
-	img, _, err := imageLib.Decode(res.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	return img, nil
-}
-
-// ImageTypedMedia implements TypedMedia for image files
-type ImageTypedMedia struct{}
-
-// IsCorrectlyUploaded for image file
-func (im *ImageTypedMedia) IsCorrectlyUploaded(uploadedFileURL, localFilePath string) (bool, error) {
-	if !IsImage(localFilePath) {
-		return false, fmt.Errorf("%s is not an image. won't delete local file", localFilePath)
-	}
-
-	// compare uploaded image and local one
-	upImg, err := imageFromURL(uploadedFileURL)
-	if err != nil {
-		return false, errors.Annotate(err, "failed getting image from URL")
-	}
-	localImg, err := imageFromPath(localFilePath)
-	if err != nil {
-		return false, errors.Annotate(err, "failed loading local image from path")
-	}
-
-	if isSimilarImages(upImg, localImg) {
-		return true, nil
-	}
-
-	return false, nil
-}
-
-// isSimilarImages checks if two images (local and uploaded) are similar visually
-// the hash used here is not a proper hash: it doesn't guarantee two images with the same hash are the same images
-// it's called a perceptual hash, and can give equal or similar hashes for two different, but visually close images.
-func isSimilarImages(upImg, localImg imageLib.Image) bool {
-	upPerceptualHash := imgsim.DifferenceHash(upImg).String()
-	localPerceptualHash := imgsim.DifferenceHash(localImg).String()
-
-	if len(upPerceptualHash) != len(localPerceptualHash) {
-		return false
-	}
-	hammingDistance := hamming.Strings(upPerceptualHash, localPerceptualHash)
-
-	return hammingDistance < len(upPerceptualHash)/16
-}
diff --git a/filetypes/video.go b/filetypes/video.go
deleted file mode 100644
index 0b9bbe4..0000000
--- a/filetypes/video.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package filetypes
-
-import (
-	"fmt"
-
-	"github.com/juju/errors"
-)
-
-// isSameVideos checks if two gifs (local and uploaded) are exactly the same
-func isSameVideos(upGifPath, localGifPath string) bool {
-	upHash, err := fileHash(upGifPath)
-	if err != nil {
-		return false
-	}
-	localHash, err := fileHash(localGifPath)
-	if err != nil {
-		return false
-	}
-
-	return upHash == localHash
-}
-
-// VideoTypedMedia implements TypedMedia for video files
-type VideoTypedMedia struct{}
-
-// IsCorrectlyUploaded checks that the video that was uploaded is the same as the local one, before deleting the local one
-func (gm *VideoTypedMedia) IsCorrectlyUploaded(uploadedFileURL, localFilePath string) (bool, error) {
-	if !IsGif(localFilePath) {
-		return false, fmt.Errorf("%s is not a gif. Not deleting local file", localFilePath)
-	}
-
-	// compare uploaded image and local one
-	if isSameVideos(uploadedFileURL, localFilePath) {
-		return true, nil
-	}
-
-	return false, errors.New("gif was not uploaded correctly. Not deleting local file")
-}
diff --git a/go.sum b/go.sum
index 28da2a6..786a314 100644
--- a/go.sum
+++ b/go.sum
@@ -25,6 +25,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a h1:mq+R6XEM6lJX5VlLyZIrUSP8tSuJp82xTK89hvBwJbU=
 github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
+github.com/elastic/go-ucfg v0.7.0 h1:1+C/sZdJKww8hKl7XtLPTjs4cFslhQF2fazKTF+ZE+4=
+github.com/elastic/go-ucfg v0.7.0/go.mod h1:iaiY0NBIYeasNgycLyTvhJftQlQEUO2hpF+FX0JKxzo=
 github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
@@ -62,6 +64,8 @@ github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHh
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hjson/hjson-go v3.0.0+incompatible h1:mc8olpIxqF8mrEx6ePJOD6wCCOkX7+JcrKgINcJES10=
+github.com/hjson/hjson-go v3.0.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
diff --git a/utils/filesystem/filesystem.go b/utils/filesystem/filesystem.go
index 71bf3c2..db38394 100644
--- a/utils/filesystem/filesystem.go
+++ b/utils/filesystem/filesystem.go
@@ -1,16 +1,40 @@
 package filesystem
 
 import (
-	"fmt"
-	"io"
-	"io/ioutil"
+	"log"
 	"os"
-	"strconv"
+	"os/user"
+	"path/filepath"
+	"strings"
 	"time"
-
-	"github.com/juju/errors"
 )
 
+// AbsolutePath converts a path (relative or absolute) into an absolute one.
+// Supports '~' notation for $HOME directory of the current user.
+func AbsolutePath(path string) string {
+	usr, err := user.Current()
+	if err != nil {
+		// It's very strange to have an error here, but in any case,
+		// return the same path was given.
+		return path
+	}
+	dir := usr.HomeDir
+
+	if path == "~" {
+		// In case of "~", which won't be caught by the "else if"
+		return dir
+	} else if strings.HasPrefix(path, "~/") {
+		// Use strings.HasPrefix so we don't match paths like
+		// "/something/~/something/"
+		return filepath.Join(dir, path[2:])
+	}
+	abspath, err := filepath.Abs(path)
+	if err != nil {
+		log.Println(err)
+	}
+	return abspath
+}
+
 // IsFile asserts there is a file at path
 func IsFile(path string) bool {
 	fi, err := os.Stat(path)
@@ -38,37 +62,3 @@ func GetMTime(path string) (mtime time.Time, err error) {
 	mtime = fi.ModTime()
 	return
 }
-
-// BufferFromFile opens the file to return a buffer
-func BufferFromFile(filePath string) (buf []byte, _ error) {
-	if !IsFile(filePath) {
-		return nil, fmt.Errorf("not a file")
-	}
-	buf, err := ioutil.ReadFile(filePath)
-	if err != nil {
-		return nil, errors.Annotatef(err, "Failed reading file: %s: Ignoring file...\n", filePath)
-	}
-
-	return buf, nil
-}
-
-// BufferHeaderFromFile opens the file to return a buffer of the first HEADERSIZE bytes
-func BufferHeaderFromFile(filePath string, howMany int64) (buf []byte, _ error) {
-	if !IsFile(filePath) {
-		return nil, fmt.Errorf("not a file")
-	}
-	r, err := os.Open(filePath)
-	if err != nil {
-		return nil, err
-	}
-
-	defer r.Close()
-
-	buf = make([]byte, howMany)
-	_, err = io.ReadFull(r, buf[:])
-	if err != nil {
-		return nil, errors.Annotatef(err, "Failed reading %s bytes of file: %s: Ignoring file...\n", strconv.FormatInt(howMany, 10), filePath)
-	}
-
-	return buf, nil
-}


Test patch:
diff --git a/config/config_test.go b/config/config_test.go
index 3baa356..b81c55c 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -3,6 +3,7 @@ package config_test
 import (
 	"fmt"
 	"os"
+	"path"
 	"path/filepath"
 	"testing"
 	"time"
@@ -10,21 +11,46 @@ import (
 	"github.com/gphotosuploader/gphotos-uploader-cli/config"
 )
 
+func TestInitConfig(t *testing.T) {
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+
+	t.Run("TestInitConfigFile", func(t *testing.T) {
+		err := config.InitConfig(dir, true)
+		if err != nil {
+			t.Errorf("could not create init config file: %v", err)
+		}
+	})
+
+	defer func() {
+		err := os.RemoveAll(dir)
+		if err != nil {
+			t.Errorf("could not remove test config file (dir: %s): %v", dir, err)
+		}
+	}()
+
+	t.Run("TestInitConfigFileWithExistentFile", func(t *testing.T) {
+		err := config.InitConfig(dir, false)
+		if err == nil {
+			t.Error("an error creating an existent file was expected")
+		}
+	})
+}
+
 func TestInitAndLoadConfig(t *testing.T) {
-	// init config file
-	path := filepath.Join(os.TempDir(), fmt.Sprintf("file.%d", time.Now().UnixNano()))
+	// init config folder
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
 
 	t.Run("TestInitConfigFile", func(t *testing.T) {
-		err := config.InitConfigFile(path)
+		err := config.InitConfig(dir, true)
 		if err != nil {
 			t.Errorf("could not create init config file: %v", err)
 		}
 	})
 
 	defer func() {
-		err := os.Remove(path)
+		err := os.RemoveAll(dir)
 		if err != nil {
-			t.Errorf("could not remove test config file (path: %s): %v", path, err)
+			t.Errorf("could not remove test config file (dir: %s): %v", dir, err)
 		}
 	}()
 
@@ -33,7 +59,7 @@ func TestInitAndLoadConfig(t *testing.T) {
 
 	t.Run("TestLoadConfigFile", func(t *testing.T) {
 		// test load config file
-		got, err := config.LoadConfigFile(path)
+		got, err := config.LoadConfig(dir)
 		if err != nil {
 			t.Errorf("could not load config file, got an error: %v", err)
 		}
@@ -49,6 +75,45 @@ func TestInitAndLoadConfig(t *testing.T) {
 	})
 }
 
+func TestLoadConfigWithNonExistentFile(t *testing.T) {
+	// init config folder
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	err := os.RemoveAll(dir)
+	if err != nil {
+		t.Errorf("could not remove test config file (dir: %s): %v", dir, err)
+	}
+
+	_, err = config.LoadConfig(dir)
+	if err == nil {
+		t.Error("an error loading a non existent file was expected")
+	}
+}
+
+func TestConfig_TrackingDBDir(t *testing.T) {
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	c := config.NewConfig(dir)
+
+	expected := path.Join(dir, "uploads.db")
+	got := c.TrackingDBDir()
+
+	if got != expected {
+		t.Errorf("Testing get tracking DB dir: expected: %s, got %s", expected, got)
+	}
+
+}
+
+func TestConfig_KeyringDir(t *testing.T) {
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	c := config.NewConfig(dir)
+
+	expected := dir
+	got := c.KeyringDir()
+
+	if got != expected {
+		t.Errorf("Testing get keyring dir: expected: %s, got %s", expected, got)
+	}
+}
+
 func createTestConfiguration() *config.Config {
 	c := &config.Config{}
 	c.SecretsBackendType = "auto"
diff --git a/datastore/tokenstore/tokenstore_test.go b/datastore/tokenstore/tokenstore_test.go
index 84db27c..069defb 100644
--- a/datastore/tokenstore/tokenstore_test.go
+++ b/datastore/tokenstore/tokenstore_test.go
@@ -1,6 +1,9 @@
 package tokenstore
 
 import (
+	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 	"time"
 
@@ -27,10 +30,17 @@ var fixedStringPrompt keyring.PromptFunc = func(_ string) (string, error) {
 }
 
 func TestStoreToken(t *testing.T) {
-	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt)
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt, dir)
 	if err != nil {
 		t.Errorf("error not expected at this stage: %v", err)
 	}
+	defer func() {
+		err := os.RemoveAll(dir)
+		if err != nil {
+			t.Error(err)
+		}
+	}()
 	s := NewService(repo)
 
 	err = s.StoreToken(userEmail, getDefaultToken())
@@ -41,10 +51,17 @@ func TestStoreToken(t *testing.T) {
 
 // TestRetrieveToken tests getting a token from the keyring.
 func TestRetrieveToken(t *testing.T) {
-	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt)
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt, dir)
 	if err != nil {
 		t.Errorf("error not expected at this stage: %v", err)
 	}
+	defer func() {
+		err := os.RemoveAll(dir)
+		if err != nil {
+			t.Error(err)
+		}
+	}()
 	s := NewService(repo)
 
 	expectedToken := getDefaultToken()
@@ -65,10 +82,17 @@ func TestRetrieveToken(t *testing.T) {
 
 // TestRetrieveExpiredToken tests getting an invalid (expired) token from the keyring.
 func TestRetrieveExpiredToken(t *testing.T) {
-	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt)
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt, dir)
 	if err != nil {
 		t.Errorf("error not expected at this stage: %v", err)
 	}
+	defer func() {
+		err := os.RemoveAll(dir)
+		if err != nil {
+			t.Error(err)
+		}
+	}()
 	s := NewService(repo)
 
 	expectedToken := getDefaultToken()
@@ -90,10 +114,17 @@ func TestRetrieveExpiredToken(t *testing.T) {
 
 // TestRetrieveInvalidToken tests getting an invalid (empty AccessToken) token from the keyring.
 func TestRetrieveInvalidToken(t *testing.T) {
-	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt)
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt, dir)
 	if err != nil {
 		t.Errorf("error not expected at this stage: %v", err)
 	}
+	defer func() {
+		err := os.RemoveAll(dir)
+		if err != nil {
+			t.Error(err)
+		}
+	}()
 	s := NewService(repo)
 
 	expectedToken := getDefaultToken()
@@ -111,10 +142,17 @@ func TestRetrieveInvalidToken(t *testing.T) {
 
 // TestRetrieveNonExistingToken tests getting a token not in the keyring.
 func TestRetrieveNonExistingToken(t *testing.T) {
-	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt)
+	dir := filepath.Join(os.TempDir(), fmt.Sprintf("gphotos-config.%d", time.Now().UnixNano()))
+	repo, err := NewKeyringRepository(secretsBackend, &fixedStringPrompt, dir)
 	if err != nil {
 		t.Errorf("error not expected at this stage: %v", err)
 	}
+	defer func() {
+		err := os.RemoveAll(dir)
+		if err != nil {
+			t.Error(err)
+		}
+	}()
 	s := NewService(repo)
 
 	_, err = s.RetrieveToken(userEmail + "fake")
diff --git a/utils/filesystem/filesystem_test.go b/utils/filesystem/filesystem_test.go
new file mode 100644
index 0000000..770f527
--- /dev/null
+++ b/utils/filesystem/filesystem_test.go
@@ -0,0 +1,132 @@
+package filesystem_test
+
+import (
+	"os"
+	"os/user"
+	"path"
+	"testing"
+
+	"github.com/gphotosuploader/gphotos-uploader-cli/utils/filesystem"
+)
+
+func TestAbsolutePath(t *testing.T) {
+
+	t.Run("WithAbsolutePaths", func(t *testing.T) {
+		var absolutePathInputs = []struct {
+			in  string
+			out string
+		}{
+			{"/", "/"},
+			{"/xyz", "/xyz"},
+			{"/xyz/./abc", "/xyz/abc"},
+			{"/xyz/../abc", "/abc"},
+			{"/xyz/abc/..", "/xyz"},
+			{"/xyz/../abc/..", "/"},
+			{"/xyz/../..", "/"},
+			{"/xyz///abc/..", "/xyz"},
+		}
+
+		for _, test := range absolutePathInputs {
+			got := filesystem.AbsolutePath(test.in)
+			if got != test.out {
+				t.Errorf("failed for '%s': expected '%v', got '%v'", test.in, test.out, got)
+			}
+		}
+	})
+
+	t.Run("WithRelativePath", func(t *testing.T) {
+		var relativePathInputs = []struct {
+			in  string
+			out string
+		}{
+			{"", ""},
+			{"./", ""},
+			{"xyz", "xyz"},
+			{"xyz/./abc", "xyz/abc"},
+			{"xyz/../abc", "abc"},
+			{"xyz/abc/..", "xyz"},
+			{"xyz/../abc/..", ""},
+			{"xyz/../..", ".."},
+		}
+
+		cwd, err := os.Getwd()
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		for _, test := range relativePathInputs {
+			got := filesystem.AbsolutePath(test.in)
+			expected := path.Join(cwd, test.out)
+
+			if got != expected {
+				t.Errorf("failed for '%s': expected '%v', got '%v'", test.in, expected, got)
+			}
+		}
+	})
+
+	t.Run("WithTildePath", func(t *testing.T) {
+		var tildePathInputs = []struct {
+			in  string
+			out string
+		}{
+			{"~", ""},
+			{"~/", ""},
+			{"~/xyz", "xyz"},
+			{"~/xyz/./abc", "xyz/abc"},
+			{"~/xyz/../abc", "abc"},
+			{"~/xyz/abc/..", "xyz"},
+			{"~/xyz/../abc/..", ""},
+			{"~/xyz/../..", ".."},
+			{"~/xyz/~/abc", "xyz/~/abc"},
+		}
+
+		usr, err := user.Current()
+		if err != nil {
+			t.Fatal(err)
+		}
+		dir := usr.HomeDir
+
+		for _, test := range tildePathInputs {
+			got := filesystem.AbsolutePath(test.in)
+			expected := path.Join(dir, test.out)
+
+			if got != expected {
+				t.Errorf("failed for '%s': expected '%v', got '%v'", test.in, expected, got)
+			}
+		}
+	})
+}
+
+func TestIsFile(t *testing.T) {
+	var objectsTest = []struct {
+		in  string
+		out bool
+	}{
+		{"testdata/file.txt", true},
+		{"testdata/folder", false},
+	}
+
+	for _, test := range objectsTest {
+		got := filesystem.IsFile(test.in)
+		if got != test.out {
+			t.Errorf("failed for '%s': expected '%v', got '%v'", test.in, test.out, got)
+		}
+	}
+}
+
+func TestIsDir(t *testing.T) {
+	var objectsTest = []struct {
+		in  string
+		out bool
+	}{
+		{"testdata/file.txt", false},
+		{"testdata/folder", true},
+	}
+
+	for _, test := range objectsTest {
+		got := filesystem.IsDir(test.in)
+		if got != test.out {
+			t.Errorf("failed for '%s': expected '%v', got '%v'", test.in, test.out, got)
+		}
+	}
+}