diff --git a/cmd/config.go b/cmd/config.go index 5796b01..81fe06f 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "github.com/bandprotocol/falcon/falcon" @@ -29,17 +30,25 @@ func configShowCmd(app *falcon.App) *cobra.Command { cmd := &cobra.Command{ Use: "show", Aliases: []string{"s", "list", "l"}, - Short: "Display global configuration", + Short: "Prints current configuration", Args: withUsage(cobra.NoArgs), Example: strings.TrimSpace(fmt.Sprintf(` $ %s config show --home %s -$ %s cfg s`, appName, defaultHome, appName)), +$ %s cfg list`, appName, defaultHome, appName)), RunE: func(cmd *cobra.Command, args []string) error { - _ = app + if app.Config == nil { + return fmt.Errorf("config does not exist: %s", app.HomePath) + } + + b, err := toml.Marshal(app.Config) + if err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), string(b)) return nil }, } - return cmd } @@ -54,10 +63,19 @@ func configInitCmd(app *falcon.App) *cobra.Command { $ %s config init --home %s $ %s cfg i`, appName, defaultHome, appName)), RunE: func(cmd *cobra.Command, args []string) error { - _ = app - return nil + home, err := cmd.Flags().GetString(flagHome) + if err != nil { + return err + } + + file, err := cmd.Flags().GetString(flagFile) + if err != nil { + return err + } + + return app.InitConfigFile(home, file) }, } - + cmd.Flags().StringP(flagFile, "f", "", "fetch toml data from specified file") return cmd } diff --git a/cmd/flags.go b/cmd/flags.go index 172f95e..ea4d1ca 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -2,4 +2,5 @@ package cmd const ( flagHome = "home" + flagFile = "file" ) diff --git a/falcon/app.go b/falcon/app.go index 8509f2c..50d8a2c 100644 --- a/falcon/app.go +++ b/falcon/app.go @@ -2,7 +2,11 @@ package falcon import ( "context" + "fmt" + "os" + "path" + "github.com/pelletier/go-toml/v2" "github.com/spf13/viper" "go.uber.org/zap" @@ -11,6 +15,11 @@ import ( "github.com/bandprotocol/falcon/falcon/chains" ) +const ( + configFolderName = "config" + configFileName = "config.toml" +) + // App is the main application struct. type App struct { Log *zap.Logger @@ -57,11 +66,81 @@ func (a *App) InitLogger(configLogLevel string) error { // loadConfigFile reads config file into a.Config if file is present. func (a *App) LoadConfigFile(ctx context.Context) error { + cfgPath := path.Join(a.HomePath, configFolderName, configFileName) + if _, err := os.Stat(cfgPath); err != nil { + // don't return error if file doesn't exist + return nil + } + + // read the config from config path + cfg, err := LoadConfig(cfgPath) + if err != nil { + return err + } + + // save configuration + a.Config = cfg + return nil } // InitConfigFile initializes the configuration to the given path. -func (a *App) InitConfigFile(homePath string) error { +func (a *App) InitConfigFile(homePath string, customFilePath string) error { + cfgDir := path.Join(homePath, configFolderName) + cfgPath := path.Join(cfgDir, configFileName) + + // check if the config file already exists + // https://stackoverflow.com/questions/12518876/how-to-check-if-a-file-exists-in-go + if _, err := os.Stat(cfgPath); err == nil { + return fmt.Errorf("config already exists: %s", cfgPath) + } else if !os.IsNotExist(err) { + return err + } + + // Load config from given custom file path if exists + var cfg *Config + var err error + switch { + case customFilePath != "": + cfg, err = LoadConfig(customFilePath) // Initialize with CustomConfig if file is provided + if err != nil { + return fmt.Errorf("LoadConfig file %v error %v", customFilePath, err) + } + default: + cfg = DefaultConfig() // Initialize with DefaultConfig if no file is provided + } + + // Marshal config object into bytes + b, err := toml.Marshal(cfg) + if err != nil { + return err + } + + // Create the home folder if doesn't exist + if _, err := os.Stat(homePath); os.IsNotExist(err) { + if err = os.Mkdir(homePath, os.ModePerm); err != nil { + return err + } + } + + // Create the config folder if doesn't exist + if _, err := os.Stat(cfgDir); os.IsNotExist(err) { + if err = os.Mkdir(cfgDir, os.ModePerm); err != nil { + return err + } + } + + // Create the file and write the default config to the given location. + f, err := os.Create(cfgPath) + if err != nil { + return err + } + defer f.Close() + + if _, err = f.Write(b); err != nil { + return err + } + return nil } diff --git a/falcon/app_test.go b/falcon/app_test.go new file mode 100644 index 0000000..89fe81a --- /dev/null +++ b/falcon/app_test.go @@ -0,0 +1,98 @@ +package falcon_test + +import ( + "os" + "path" + "testing" + "time" + + "github.com/pelletier/go-toml/v2" + "github.com/stretchr/testify/require" + + "github.com/bandprotocol/falcon/falcon" + "github.com/bandprotocol/falcon/falcon/band" +) + +func TestInitConfig(t *testing.T) { + tmpDir := t.TempDir() + customCfgPath := "" + + app := falcon.NewApp(nil, nil, tmpDir, false, nil) + + err := app.InitConfigFile(tmpDir, customCfgPath) + require.NoError(t, err) + + require.FileExists(t, path.Join(tmpDir, "config", "config.toml")) + + // read the file + b, err := os.ReadFile(path.Join(tmpDir, "config", "config.toml")) + require.NoError(t, err) + + // unmarshal data + actual := &falcon.Config{} + err = toml.Unmarshal(b, actual) + require.NoError(t, err) + + expect := falcon.DefaultConfig() + + require.Equal(t, *expect, *actual) +} + +func TestInitExistingConfig(t *testing.T) { + tmpDir := t.TempDir() + customCfgPath := "" + + app := falcon.NewApp(nil, nil, tmpDir, false, nil) + + err := app.InitConfigFile(tmpDir, customCfgPath) + require.NoError(t, err) + + // second time should fail + err = app.InitConfigFile(tmpDir, customCfgPath) + require.ErrorContains(t, err, "config already exists:") +} + +func TestInitCustomConfig(t *testing.T) { + tmpDir := t.TempDir() + customCfgPath := path.Join(tmpDir, "custom.toml") + + // Create custom config file + cfg := ` + target_chains = [] + checking_packet_interval = 60000000000 + + [bandchain] + rpc_endpoints = ['http://localhost:26659'] + timeout = 50 + ` + // write file + err := os.WriteFile(customCfgPath, []byte(cfg), 0o600) + require.NoError(t, err) + + app := falcon.NewApp(nil, nil, tmpDir, false, nil) + + err = app.InitConfigFile(tmpDir, customCfgPath) + require.NoError(t, err) + + require.FileExists(t, path.Join(tmpDir, "config", "config.toml")) + + // read the file + b, err := os.ReadFile(path.Join(tmpDir, "config", "config.toml")) + require.NoError(t, err) + + // unmarshal data + actual := falcon.Config{} + err = toml.Unmarshal(b, &actual) + require.NoError(t, err) + + expect := falcon.Config{ + BandChainConfig: band.Config{ + RpcEndpoints: []string{"http://localhost:26659"}, + Timeout: 50, + }, + TargetChains: []falcon.TargetChainConfig{}, + CheckingPacketInterval: time.Minute, + } + + require.Equal(t, expect, actual) +} diff --git a/falcon/config.go b/falcon/config.go index 76d1d67..4d584b2 100644 --- a/falcon/config.go +++ b/falcon/config.go @@ -1,8 +1,11 @@ package falcon import ( + "os" "time" + "github.com/pelletier/go-toml/v2" + "github.com/bandprotocol/falcon/falcon/band" "github.com/bandprotocol/falcon/falcon/chains" ) @@ -20,3 +23,31 @@ type Config struct { TargetChains []TargetChainConfig `toml:"target_chains"` CheckingPacketInterval time.Duration `toml:"checking_packet_interval"` } + +// DefaultConfig returns the default configuration. +func DefaultConfig() *Config { + return &Config{ + BandChainConfig: band.Config{ + RpcEndpoints: []string{"http://localhost:26657"}, + Timeout: 5, + }, + TargetChains: []TargetChainConfig{}, + CheckingPacketInterval: time.Minute, + } +} + +// LoadConfig reads config file from given path and return config object +func LoadConfig(cfgPath string) (*Config, error) { + byt, err := os.ReadFile(cfgPath) + if err != nil { + return &Config{}, err + } + + // unmarshall them with Config into struct + cfg := &Config{} + err = toml.Unmarshal(byt, cfg) + if err != nil { + return &Config{}, err + } + return cfg, nil +} diff --git a/falcon/config_test.go b/falcon/config_test.go new file mode 100644 index 0000000..58efd40 --- /dev/null +++ b/falcon/config_test.go @@ -0,0 +1,50 @@ +package falcon_test + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/bandprotocol/falcon/falcon" + falcon_test "github.com/bandprotocol/falcon/falcon/falcontest" +) + +func TestShowConfig(t *testing.T) { + sys := falcon_test.NewSystem(t) + + res := sys.RunWithInput(t, "config", "init") + require.NoError(t, res.Err) + + res = sys.RunWithInput(t, "config", "show") + require.NoError(t, res.Err) + + actual := res.Stdout.String() + expect := "target_chains = []\nchecking_packet_interval = 60000000000\n\n[bandchain]\nrpc_endpoints = ['http://localhost:26657']\ntimeout = 5\n\n" + + require.Equal(t, expect, actual) +} + +func TestShowEmptyConfig(t *testing.T) { + sys := falcon_test.NewSystem(t) + + res := sys.RunWithInput(t, "config", "show") + require.ErrorContains(t, res.Err, "config does not exist:") +} + +func TestLoadConfig(t *testing.T) { + tmpDir := t.TempDir() + customConfigPath := "" + cfgPath := path.Join(tmpDir, "config", "config.toml") + + app := falcon.NewApp(nil, nil, tmpDir, false, nil) + + // Prepare config before test + err := app.InitConfigFile(tmpDir, customConfigPath) + require.NoError(t, err) + + actual, err := falcon.LoadConfig(cfgPath) + require.NoError(t, err) + expect := falcon.DefaultConfig() + require.Equal(t, expect, actual) +} diff --git a/falcon/falcontest/system.go b/falcon/falcontest/system.go new file mode 100644 index 0000000..2fd27ed --- /dev/null +++ b/falcon/falcontest/system.go @@ -0,0 +1,59 @@ +package falcon_test + +import ( + "bytes" + "context" + "testing" + + "go.uber.org/zap/zaptest" + + "github.com/bandprotocol/falcon/cmd" +) + +// System is a system under test. +type System struct { + // Temporary directory to be injected as --home argument. + HomeDir string +} + +// NewSystem creates a new system with a home dir associated with a temp dir belonging to t. +// +// The returned System does not store a reference to t; +// some of its methods expect a *testing.T as an argument. +// This allows creating one instance of System to be shared with subtests. +func NewSystem(t *testing.T) *System { + t.Helper() + + homeDir := t.TempDir() + + return &System{ + HomeDir: homeDir, + } +} + +// RunResult is the stdout and stderr resulting from a call to (*System).Run, +// and any error that was returned. +type RunResult struct { + Stdout, Stderr bytes.Buffer + + Err error +} + +func (s *System) RunWithInput(t *testing.T, args ...string) RunResult { + rootCmd := cmd.NewRootCmd(zaptest.NewLogger(t)) + + rootCmd.SilenceUsage = true + + ctx := context.Background() + + var res RunResult + rootCmd.SetOut(&res.Stdout) + rootCmd.SetErr(&res.Stderr) + + // Prepend the system's home directory to any provided args. + args = append([]string{"--home", s.HomeDir}, args...) + rootCmd.SetArgs(args) + + res.Err = rootCmd.ExecuteContext(ctx) + return res +} diff --git a/go.mod b/go.mod index 1cab16c..c054a64 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,21 @@ go 1.22.3 require ( github.com/cometbft/cometbft v0.38.12 github.com/jsternberg/zap-logfmt v1.3.0 + github.com/pelletier/go-toml/v2 v2.2.2 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect