diff --git a/Makefile b/Makefile index c2bb431d..9a923ed5 100644 --- a/Makefile +++ b/Makefile @@ -95,7 +95,7 @@ mockery: .PHONY: test_unit test_unit: - go test ./cmd/... + go test ./cmd/... ./services/... ./output/... # add binary and config to tests/cmd/bin/ before run test integration .PHONY: test_integration diff --git a/cmd/cmd_root.go b/cmd/cmd_root.go index f80ef367..f1e2b22c 100644 --- a/cmd/cmd_root.go +++ b/cmd/cmd_root.go @@ -18,6 +18,7 @@ var ( insecure bool proxy string verbose int + showConfig bool isInit bool isFiles bool @@ -33,14 +34,8 @@ func NewRootCmd() *cobra.Command { Version: "3.1", Long: `Manage translation files using Smartling CLI. Complete documentation is available at https://www.smartling.com`, - PersistentPreRun: func(cmd *cobra.Command, _ []string) { - configureLoggerVerbose() - - path := cmd.CommandPath() - isInit = strings.HasPrefix(path, "smartling-cli init") - isFiles = strings.HasPrefix(path, "smartling-cli files") - isProjects = strings.HasPrefix(path, "smartling-cli projects") - isList = strings.HasPrefix(path, "smartling-cli list") + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return RunRootPersistentPreRun(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 && cmd.Flags().NFlag() == 0 { @@ -74,6 +69,21 @@ executed for at most of threads.`) rootCmd.PersistentFlags().StringVar(&smartlingURL, "smartling-url", "", `Specify base Smartling URL, merely for testing purposes.`) rootCmd.PersistentFlags().CountVarP(&verbose, "verbose", "v", "Verbose logging") + rootCmd.PersistentFlags().BoolVar(&showConfig, "show-config", false, + `Print the resolved account, project, user, and config file path +to stderr before the command runs.`) return rootCmd } + +func RunRootPersistentPreRun(cmd *cobra.Command) error { + configureLoggerVerbose() + + path := cmd.CommandPath() + isInit = strings.HasPrefix(path, "smartling-cli init") + isFiles = strings.HasPrefix(path, "smartling-cli files") + isProjects = strings.HasPrefix(path, "smartling-cli projects") + isList = strings.HasPrefix(path, "smartling-cli list") + + return ShowConfigBanner(cmd.Context()) +} diff --git a/cmd/config_banner.go b/cmd/config_banner.go new file mode 100644 index 00000000..1683095c --- /dev/null +++ b/cmd/config_banner.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + output "github.com/Smartling/smartling-cli/output/projects" + "github.com/Smartling/smartling-cli/services/helpers/rlog" + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" + + "golang.org/x/term" +) + +// ShowConfigBanner prints the --show-config banner to stdout when the +// flag is set, and — only on an interactive terminal — prompts the operator +// with "Continue? [y/N]:" before letting the command proceed. +func ShowConfigBanner(ctx context.Context) error { + if !showConfig || isInit { + return nil + } + config, err := Config() + if err != nil { + return fmt.Errorf("failed to read config: %w", err) + } + var extendedConfig projectconfig.Extended + extendedConfig.InjectConfig(config) + client, err := Client(ctx) + if err != nil { + rlog.Errorf("failed to create API client: %s", err) + } else if config.ProjectID != "" { + projectDetails, err := client.GetProjectDetails(ctx, config.ProjectID) + if err != nil { + rlog.Errorf("failed to fetch project details: %s", err) + } + if projectDetails != nil { + extendedConfig.InjectProject(*projectDetails) + } + } + if !showConfigAndMaybePrompt(extendedConfig, os.Stdout, os.Stderr, os.Stdin, stdinIsTerminal()) { + return errors.New("operation aborted by user") + } + return nil +} + +func showConfigAndMaybePrompt(config projectconfig.Extended, stdoutW, stderrW io.Writer, stdinR io.Reader, stdinIsTerminal bool) bool { + _ = output.RenderPlain(stdoutW, config) + + if !stdinIsTerminal { + return true + } + + _, _ = fmt.Fprint(stderrW, "Continue? [y/N]: ") + if !confirmContinue(stdinR) { + _, _ = fmt.Fprintln(stderrW, "aborted") + return false + } + return true +} + +func confirmContinue(r io.Reader) bool { + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + return false + } + switch strings.ToLower(strings.TrimSpace(scanner.Text())) { + case "y", "yes": + return true + default: + return false + } +} + +// stdinIsTerminal reports whether stdin is connected to an interactive terminal. +func stdinIsTerminal() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} diff --git a/cmd/config_banner_test.go b/cmd/config_banner_test.go new file mode 100644 index 00000000..61620cd8 --- /dev/null +++ b/cmd/config_banner_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" +) + +func testExtendedConfig() projectconfig.Extended { + return projectconfig.Extended{ + ProjectID: "p-789", + AccountUID: "a-456", + Name: "Acme Localization", + Locale: "en-US: English (United States)", + Status: "active", + UserID: "u-123", + ConfigFile: "/tmp/smartling.yml", + Sources: "project=flag account=config user=env", + } +} + +func TestConfirmContinue(t *testing.T) { + tests := []struct { + name, input string + want bool + }{ + {"y lowercase", "y\n", true}, + {"Y uppercase", "Y\n", true}, + {"yes", "yes\n", true}, + {"YES", "YES\n", true}, + {"n", "n\n", false}, + {"N", "N\n", false}, + {"no", "no\n", false}, + {"empty line", "\n", false}, + {"eof", "", false}, + {"whitespace around y", " y \n", true}, + {"whitespace around yes", " yes \n", true}, + {"unrelated text", "foo\n", false}, + {"y-prefixed word yeti", "yeti\n", false}, + {"y-prefixed word yikes", "yikes\n", false}, + {"yy", "yy\n", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := confirmContinue(strings.NewReader(tt.input)); got != tt.want { + t.Errorf("confirmContinue(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestShowConfigAndMaybePrompt_NonTTY(t *testing.T) { + var stdout, stderr bytes.Buffer + got := showConfigAndMaybePrompt(testExtendedConfig(), &stdout, &stderr, strings.NewReader(""), false) + + if !got { + t.Errorf("showConfigAndMaybePrompt should return true in non-TTY case, got false") + } + if !strings.Contains(stdout.String(), "Smartling CLI configuration:") { + t.Errorf("stdout should contain banner, got: %q", stdout.String()) + } + if stderr.String() != "" { + t.Errorf("stderr should be empty (no prompt) in non-TTY case, got: %q", stderr.String()) + } +} + +func TestShowConfigAndMaybePrompt_TTYConfirm(t *testing.T) { + var stdout, stderr bytes.Buffer + got := showConfigAndMaybePrompt(testExtendedConfig(), &stdout, &stderr, strings.NewReader("y\n"), true) + + if !got { + t.Errorf("showConfigAndMaybePrompt should return true on 'y', got false") + } + if !strings.Contains(stdout.String(), "Smartling CLI configuration:") { + t.Errorf("stdout should contain banner, got: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "Continue?") { + t.Errorf("stderr should contain prompt, got: %q", stderr.String()) + } +} + +func TestShowConfigAndMaybePrompt_TTYAbort(t *testing.T) { + var stdout, stderr bytes.Buffer + got := showConfigAndMaybePrompt(testExtendedConfig(), &stdout, &stderr, strings.NewReader("n\n"), true) + + if got { + t.Errorf("showConfigAndMaybePrompt should return false on 'n', got true") + } + if !strings.Contains(stderr.String(), "aborted") { + t.Errorf("stderr should contain 'aborted' on abort, got: %q", stderr.String()) + } +} diff --git a/cmd/docs/cmd_docs.go b/cmd/docs/cmd_docs.go index d5e70b07..34c9981b 100644 --- a/cmd/docs/cmd_docs.go +++ b/cmd/docs/cmd_docs.go @@ -18,7 +18,7 @@ func NewDocsCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { err := doc.GenMarkdownTree(cmd.Root(), "./docs") if err != nil { - return fmt.Errorf("failed to generate docs: %v", err) + return fmt.Errorf("failed to generate docs: %w", err) } rlog.Infof("markdown docs generated in ./docs/") return nil diff --git a/cmd/jobs/cmd_jobs.go b/cmd/jobs/cmd_jobs.go index cf61141c..c20d4fdd 100644 --- a/cmd/jobs/cmd_jobs.go +++ b/cmd/jobs/cmd_jobs.go @@ -5,6 +5,8 @@ import ( "slices" "strings" + "github.com/Smartling/smartling-cli/cmd" + "github.com/spf13/cobra" ) @@ -50,7 +52,10 @@ Available options: smartling-cli jobs progress aabbccdd --output json `, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + PersistentPreRunE: func(c *cobra.Command, args []string) error { + if err := cmd.RunRootPersistentPreRun(c); err != nil { + return err + } if !slices.Contains(allowedOutputs, outputFormat) { return fmt.Errorf("invalid output: %s (allowed: %s)", outputFormat, joinedAllowedOutputs) } diff --git a/cmd/mt/cmd_mt.go b/cmd/mt/cmd_mt.go index d86d1d76..a0029f28 100644 --- a/cmd/mt/cmd_mt.go +++ b/cmd/mt/cmd_mt.go @@ -5,6 +5,8 @@ import ( "slices" "strings" + "github.com/Smartling/smartling-cli/cmd" + "github.com/spf13/cobra" ) @@ -35,7 +37,10 @@ func NewMTCmd() *cobra.Command { Use: "mt", Short: "File Machine Translations", Long: `Machine Translations offers a simple way to upload files and execute actions on them without any complex setup required`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + PersistentPreRunE: func(c *cobra.Command, args []string) error { + if err := cmd.RunRootPersistentPreRun(c); err != nil { + return err + } if !slices.Contains(allowedOutputs, outputFormat) { return fmt.Errorf("invalid output: %s (allowed: %s)", outputFormat, joinedAllowedOutputs) } diff --git a/cmd/projects/info/cmd_info.go b/cmd/projects/info/cmd_info.go index 1c0a6d02..37337b8a 100644 --- a/cmd/projects/info/cmd_info.go +++ b/cmd/projects/info/cmd_info.go @@ -4,6 +4,7 @@ import ( "os" projectscmd "github.com/Smartling/smartling-cli/cmd/projects" + output "github.com/Smartling/smartling-cli/output/projects" "github.com/Smartling/smartling-cli/services/helpers/help" "github.com/Smartling/smartling-cli/services/helpers/rlog" @@ -35,11 +36,15 @@ Available options:` + help.AuthenticationOptions, rlog.Errorf("failed to get project service: %s", err) os.Exit(1) } - err = s.RunInfo(ctx) + infoOutput, err := s.RunInfo(ctx) if err != nil { rlog.Errorf("failed to run info: %s", err) os.Exit(1) } + if err := output.RenderTable(infoOutput); err != nil { + rlog.Errorf("failed to render info output: %s", err) + os.Exit(1) + } }, } return infoCmd diff --git a/cmd/projects/info/cmd_info_test.go b/cmd/projects/info/cmd_info_test.go index 36d2838d..81407e95 100644 --- a/cmd/projects/info/cmd_info_test.go +++ b/cmd/projects/info/cmd_info_test.go @@ -7,6 +7,7 @@ import ( "testing" cmdmocks "github.com/Smartling/smartling-cli/cmd/projects/mocks" + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" srvmocks "github.com/Smartling/smartling-cli/services/projects/mocks" "github.com/stretchr/testify/mock" @@ -19,7 +20,7 @@ func TestNewInfoCmd(t *testing.T) { if _, err := fmt.Fprintf(buf, "RunInfo was called with %d args\n", len(args)); err != nil { t.Fatal(err) } - }).Return(nil) + }).Return(projectconfig.Extended{}, nil) initializer := cmdmocks.NewMockSrvInitializer(t) initializer.On("InitProjectsSrv", mock.Anything).Return(projectsSrv, nil) diff --git a/go.mod b/go.mod index 2a30c0b4..a71c24d6 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 golang.org/x/sync v0.20.0 + golang.org/x/term v0.43.0 ) require ( @@ -47,7 +48,6 @@ require ( github.com/zazab/zhash v0.0.0-20221031090444-2b0d50417446 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/output/projects/info.go b/output/projects/info.go new file mode 100644 index 00000000..ec00122e --- /dev/null +++ b/output/projects/info.go @@ -0,0 +1,56 @@ +package projects + +import ( + "fmt" + "io" + "os" + + "github.com/Smartling/smartling-cli/services/helpers/table" + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" +) + +func RenderTable(config projectconfig.Extended) error { + tableWriter := table.NewTableWriter(os.Stdout) + for _, row := range buildInfoRows(config) { + if _, err := fmt.Fprintf(tableWriter, "%s\t%s\n", row...); err != nil { + return err + } + } + return table.Render(tableWriter) +} + +// RenderPlain writes a multi-line summary of the resolved configuration to w. +// Used by the --show-config persistent flag. Every line is prefixed with "> " +// so the banner is easy to grep out of mixed output if needed. +func RenderPlain(w io.Writer, config projectconfig.Extended) error { + lines := []string{ + "Smartling CLI configuration:", + fmt.Sprintf(" Config file: %s", config.ConfigFile), + fmt.Sprintf(" User: %s", config.UserID), + fmt.Sprintf(" Account: %s", config.AccountUID), + fmt.Sprintf(" Project: %s", config.ProjectID), + fmt.Sprintf(" Project Name: %s", config.Name), + fmt.Sprintf(" Locale: %s", config.Locale), + fmt.Sprintf(" Status: %s", config.Status), + fmt.Sprintf(" Sources: %s", config.Sources), + } + for _, line := range lines { + if _, err := fmt.Fprintf(w, "> %s\n", line); err != nil { + return err + } + } + return nil +} + +func buildInfoRows(config projectconfig.Extended) [][]any { + return [][]any{ + {"ID", config.ProjectID}, + {"ACCOUNT", config.AccountUID}, + {"NAME", config.Name}, + {"LOCALE", config.Locale}, + {"STATUS", config.Status}, + {"USER", config.UserID}, + {"CONFIG", config.ConfigFile}, + {"SOURCES", config.Sources}, + } +} diff --git a/output/projects/info_test.go b/output/projects/info_test.go new file mode 100644 index 00000000..d7238d0f --- /dev/null +++ b/output/projects/info_test.go @@ -0,0 +1,86 @@ +package projects + +import ( + "bytes" + "strings" + "testing" + + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" +) + +func testExtendedConfig() projectconfig.Extended { + return projectconfig.Extended{ + ProjectID: "p-789", + AccountUID: "a-456", + Name: "Acme Localization", + Locale: "en-US: English (United States)", + Status: "active", + UserID: "u-123", + ConfigFile: "/tmp/smartling.yml", + Sources: "project=flag account=config user=env", + } +} + +func TestRenderPlain_AllFieldsSet(t *testing.T) { + var buf bytes.Buffer + if err := RenderPlain(&buf, testExtendedConfig()); err != nil { + t.Fatalf("RenderPlain: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "Smartling CLI configuration:", + "Config file: /tmp/smartling.yml", + "User: u-123", + "Account: a-456", + "Project: p-789", + "Project Name: Acme Localization", + "Locale: en-US: English (United States)", + "Status: active", + "Sources: project=flag account=config user=env", + } { + if !strings.Contains(out, want) { + t.Errorf("banner missing %q\nfull output:\n%s", want, out) + } + } +} + +func TestRenderPlain_EmptyFields(t *testing.T) { + cfg := projectconfig.Extended{ConfigFile: "/tmp/smartling.yml"} + + var buf bytes.Buffer + if err := RenderPlain(&buf, cfg); err != nil { + t.Fatalf("RenderPlain: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "User: ", + "Account: ", + "Project: ", + "Project Name: ", + "Locale: ", + "Status: ", + "Sources: ", + } { + if !strings.Contains(out, want) { + t.Errorf("banner missing %q for empty field:\n%s", want, out) + } + } +} + +func TestRenderPlain_LinePrefixes(t *testing.T) { + var buf bytes.Buffer + if err := RenderPlain(&buf, testExtendedConfig()); err != nil { + t.Fatalf("RenderPlain: %v", err) + } + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 9 { + t.Fatalf("expected 9 lines, got %d:\n%s", len(lines), buf.String()) + } + for i, line := range lines { + if !strings.HasPrefix(line, "> ") { + t.Errorf("line %d does not start with '> ': %q", i, line) + } + } +} diff --git a/services/helpers/config/config.go b/services/helpers/config/config.go index f91d7e01..1ded371e 100644 --- a/services/helpers/config/config.go +++ b/services/helpers/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" clierror "github.com/Smartling/smartling-cli/services/helpers/cli_error" @@ -11,6 +12,35 @@ import ( "github.com/reconquest/hierr-go" ) +// Source identifies where a configuration value was resolved from. +type Source string + +// Source values, ordered by precedence (lowest first). +const ( + SourceDefault Source = "default" + SourceConfig Source = "config" + SourceEnv Source = "env" + SourceFlag Source = "flag" +) + +// Sources records the origin of each configuration value that supports +// flag/env/file precedence. It is populated by BuildConfigFromFlags and is +// intentionally not serialized to YAML. +type Sources struct { + UserID Source + AccountID Source + ProjectID Source +} + +func (s Sources) String() string { + return fmt.Sprintf( + "project=%s account=%s user=%s", + s.ProjectID, + s.AccountID, + s.UserID, + ) +} + // FileConfig is the configuration from file. type FileConfig struct { Pull struct { @@ -35,6 +65,8 @@ type Config struct { Proxy string `yaml:"proxy,omitzero"` Path string `yaml:"-"` + + Sources Sources `yaml:"-"` } // GetFileConfig returns the FileConfig for the given path. diff --git a/services/helpers/config/constructor.go b/services/helpers/config/constructor.go index 1ae081e1..73bee8a3 100644 --- a/services/helpers/config/constructor.go +++ b/services/helpers/config/constructor.go @@ -46,8 +46,26 @@ func BuildConfigFromFlags(params Params) (Config, error) { ) } + config.Sources = Sources{ + UserID: SourceDefault, + AccountID: SourceDefault, + ProjectID: SourceDefault, + } + if config.UserID != "" { + config.Sources.UserID = SourceConfig + } + if config.AccountID != "" { + config.Sources.AccountID = SourceConfig + } + if config.ProjectID != "" { + config.Sources.ProjectID = SourceConfig + } + if config.UserID == "" { - config.UserID = os.Getenv("SMARTLING_USER_ID") + if v := os.Getenv("SMARTLING_USER_ID"); v != "" { + config.UserID = v + config.Sources.UserID = SourceEnv + } } if config.Secret == "" { @@ -55,11 +73,15 @@ func BuildConfigFromFlags(params Params) (Config, error) { } if config.ProjectID == "" { - config.ProjectID = os.Getenv("SMARTLING_PROJECT_ID") + if v := os.Getenv("SMARTLING_PROJECT_ID"); v != "" { + config.ProjectID = v + config.Sources.ProjectID = SourceEnv + } } if params.User != "" { config.UserID = params.User + config.Sources.UserID = SourceFlag } if params.Secret != "" { @@ -68,10 +90,12 @@ func BuildConfigFromFlags(params Params) (Config, error) { if params.Account != "" { config.AccountID = params.Account + config.Sources.AccountID = SourceFlag } if params.Project != "" { config.ProjectID = params.Project + config.Sources.ProjectID = SourceFlag } if !params.IsInit { diff --git a/services/helpers/config/constructor_test.go b/services/helpers/config/constructor_test.go new file mode 100644 index 00000000..07bfe443 --- /dev/null +++ b/services/helpers/config/constructor_test.go @@ -0,0 +1,139 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Smartling/smartling-cli/services/helpers/rlog" +) + +func TestMain(m *testing.M) { + rlog.Init() + os.Exit(m.Run()) +} + +func writeTempConfig(t *testing.T, contents string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "smartling.yml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write temp config: %v", err) + } + return dir +} + +func TestBuildConfigFromFlags_SourcesAllDefault(t *testing.T) { + t.Setenv("SMARTLING_USER_ID", "") + t.Setenv("SMARTLING_SECRET", "") + t.Setenv("SMARTLING_PROJECT_ID", "") + + dir := writeTempConfig(t, "") + + cfg, err := BuildConfigFromFlags(Params{ + Directory: dir, + IsInit: true, + }) + if err != nil { + t.Fatalf("BuildConfigFromFlags: %v", err) + } + + if cfg.Sources.UserID != SourceDefault { + t.Errorf("UserID source = %q, want %q", cfg.Sources.UserID, SourceDefault) + } + if cfg.Sources.AccountID != SourceDefault { + t.Errorf("AccountID source = %q, want %q", cfg.Sources.AccountID, SourceDefault) + } + if cfg.Sources.ProjectID != SourceDefault { + t.Errorf("ProjectID source = %q, want %q", cfg.Sources.ProjectID, SourceDefault) + } +} + +func TestBuildConfigFromFlags_SourcesFromFile(t *testing.T) { + t.Setenv("SMARTLING_USER_ID", "") + t.Setenv("SMARTLING_SECRET", "") + t.Setenv("SMARTLING_PROJECT_ID", "") + + dir := writeTempConfig(t, ` +user_id: "file-user" +secret: "file-secret" +account_id: "file-account" +project_id: "file-project" +`) + + cfg, err := BuildConfigFromFlags(Params{Directory: dir}) + if err != nil { + t.Fatalf("BuildConfigFromFlags: %v", err) + } + + if cfg.Sources.UserID != SourceConfig { + t.Errorf("UserID source = %q, want %q", cfg.Sources.UserID, SourceConfig) + } + if cfg.Sources.AccountID != SourceConfig { + t.Errorf("AccountID source = %q, want %q", cfg.Sources.AccountID, SourceConfig) + } + if cfg.Sources.ProjectID != SourceConfig { + t.Errorf("ProjectID source = %q, want %q", cfg.Sources.ProjectID, SourceConfig) + } +} + +func TestBuildConfigFromFlags_SourcesFromEnv(t *testing.T) { + t.Setenv("SMARTLING_USER_ID", "env-user") + t.Setenv("SMARTLING_SECRET", "env-secret") + t.Setenv("SMARTLING_PROJECT_ID", "env-project") + + dir := writeTempConfig(t, "") + + cfg, err := BuildConfigFromFlags(Params{Directory: dir}) + if err != nil { + t.Fatalf("BuildConfigFromFlags: %v", err) + } + + if cfg.Sources.UserID != SourceEnv { + t.Errorf("UserID source = %q, want %q", cfg.Sources.UserID, SourceEnv) + } + if cfg.Sources.ProjectID != SourceEnv { + t.Errorf("ProjectID source = %q, want %q", cfg.Sources.ProjectID, SourceEnv) + } + if cfg.Sources.AccountID != SourceDefault { + t.Errorf("AccountID source = %q, want %q", cfg.Sources.AccountID, SourceDefault) + } +} + +func TestBuildConfigFromFlags_SourcesFromFlags(t *testing.T) { + t.Setenv("SMARTLING_USER_ID", "env-user") + t.Setenv("SMARTLING_SECRET", "env-secret") + t.Setenv("SMARTLING_PROJECT_ID", "env-project") + + dir := writeTempConfig(t, ` +user_id: "file-user" +secret: "file-secret" +account_id: "file-account" +project_id: "file-project" +`) + + cfg, err := BuildConfigFromFlags(Params{ + Directory: dir, + User: "flag-user", + Secret: "flag-secret", + Account: "flag-account", + Project: "flag-project", + }) + if err != nil { + t.Fatalf("BuildConfigFromFlags: %v", err) + } + + if cfg.Sources.UserID != SourceFlag { + t.Errorf("UserID source = %q, want %q", cfg.Sources.UserID, SourceFlag) + } + if cfg.Sources.AccountID != SourceFlag { + t.Errorf("AccountID source = %q, want %q", cfg.Sources.AccountID, SourceFlag) + } + if cfg.Sources.ProjectID != SourceFlag { + t.Errorf("ProjectID source = %q, want %q", cfg.Sources.ProjectID, SourceFlag) + } + + if cfg.UserID != "flag-user" { + t.Errorf("UserID = %q, want %q", cfg.UserID, "flag-user") + } +} diff --git a/services/projects/config/extended_config.go b/services/projects/config/extended_config.go new file mode 100644 index 00000000..771a4f49 --- /dev/null +++ b/services/projects/config/extended_config.go @@ -0,0 +1,77 @@ +package projectconfig + +import ( + "context" + + sdkerror "github.com/Smartling/api-sdk-go/helpers/sm_error" + "github.com/Smartling/smartling-cli/services/helpers/cli_error" + "github.com/Smartling/smartling-cli/services/helpers/config" + + sdk "github.com/Smartling/api-sdk-go" + "github.com/reconquest/hierr-go" +) + +// Extended is local config joined with API-fetched project facts. +type Extended struct { + ProjectID string + Name string + AccountUID string + Locale string + Status string + UserID string + ConfigFile string + Sources string +} + +func (e *Extended) InjectConfig(cfg config.Config) { + e.AccountUID = cfg.AccountID + e.UserID = cfg.UserID + e.ProjectID = cfg.ProjectID + e.ConfigFile = cfg.Path + e.Sources = cfg.Sources.String() +} + +func (e *Extended) InjectProject(project sdk.ProjectDetails) { + e.ProjectID = project.ProjectID + e.AccountUID = project.AccountUID + e.Name = project.ProjectName + e.Locale = project.SourceLocaleID + ": " + project.SourceLocaleDescription + e.Status = getStatus(project) +} + +// FetchExtendedConfig fetches project details and merges with local config. +func FetchExtendedConfig(ctx context.Context, config config.Config, + projectFetcher func(ctx context.Context, projectID string) (*sdk.ProjectDetails, error), +) (Extended, error) { + details, err := projectFetcher(ctx, config.ProjectID) + if err != nil { + if _, ok := err.(sdkerror.NotFoundError); ok { + return Extended{}, clierror.ProjectNotFoundError{} + } + + return Extended{}, hierr.Errorf( + err, + `unable to get project "%s" details`, + config.ProjectID, + ) + } + + infoOutput := toExtended(*details, config) + return infoOutput, nil +} + +// toExtended flattens project details + local config into Extended. +func toExtended(project sdk.ProjectDetails, cfg config.Config) Extended { + var res Extended + res.InjectConfig(cfg) + res.InjectProject(project) + return res +} + +// getStatus returns "archived" or "active". +func getStatus(details sdk.ProjectDetails) string { + if details.Archived { + return "archived" + } + return "active" +} diff --git a/services/projects/mocks/projects.go b/services/projects/mocks/projects.go index d207bfae..8e548f6e 100644 --- a/services/projects/mocks/projects.go +++ b/services/projects/mocks/projects.go @@ -8,6 +8,7 @@ import ( "context" "github.com/Smartling/smartling-cli/services/projects" + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" mock "github.com/stretchr/testify/mock" ) @@ -16,7 +17,8 @@ import ( func NewMockService(t interface { mock.TestingT Cleanup(func()) -}) *MockService { +}, +) *MockService { mock := &MockService{} mock.Mock.Test(t) @@ -39,20 +41,29 @@ func (_m *MockService) EXPECT() *MockService_Expecter { } // RunInfo provides a mock function for the type MockService -func (_mock *MockService) RunInfo(ctx context.Context) error { +func (_mock *MockService) RunInfo(ctx context.Context) (projectconfig.Extended, error) { ret := _mock.Called(ctx) if len(ret) == 0 { panic("no return value specified for RunInfo") } - var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { + var r0 projectconfig.Extended + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (projectconfig.Extended, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) projectconfig.Extended); ok { r0 = returnFunc(ctx) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(projectconfig.Extended) } - return r0 + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 } // MockService_RunInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunInfo' @@ -79,12 +90,12 @@ func (_c *MockService_RunInfo_Call) Run(run func(ctx context.Context)) *MockServ return _c } -func (_c *MockService_RunInfo_Call) Return(err error) *MockService_RunInfo_Call { - _c.Call.Return(err) +func (_c *MockService_RunInfo_Call) Return(extendedConfig projectconfig.Extended, err error) *MockService_RunInfo_Call { + _c.Call.Return(extendedConfig, err) return _c } -func (_c *MockService_RunInfo_Call) RunAndReturn(run func(ctx context.Context) error) *MockService_RunInfo_Call { +func (_c *MockService_RunInfo_Call) RunAndReturn(run func(ctx context.Context) (projectconfig.Extended, error)) *MockService_RunInfo_Call { _c.Call.Return(run) return _c } diff --git a/services/projects/run_info.go b/services/projects/run_info.go index 695fe108..056c993d 100644 --- a/services/projects/run_info.go +++ b/services/projects/run_info.go @@ -2,61 +2,12 @@ package projects import ( "context" - "fmt" - "os" - "github.com/Smartling/smartling-cli/services/helpers/cli_error" - "github.com/Smartling/smartling-cli/services/helpers/table" - - sdkerror "github.com/Smartling/api-sdk-go/helpers/sm_error" - "github.com/reconquest/hierr-go" + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" ) -// RunInfo retrieves and output project details. -// Returns an error if any -func (s service) RunInfo(ctx context.Context) error { - details, err := s.Client.GetProjectDetails(ctx, s.Config.ProjectID) - if err != nil { - if _, ok := err.(sdkerror.NotFoundError); ok { - return clierror.ProjectNotFoundError{} - } - - return hierr.Errorf( - err, - `unable to get project "%s" details`, - s.Config.ProjectID, - ) - } - - tableWriter := table.NewTableWriter(os.Stdout) - - status := "active" - - if details.Archived { - status = "archived" - } - - info := [][]any{ - {"ID", details.ProjectID}, - {"ACCOUNT", details.AccountUID}, - {"NAME", details.ProjectName}, - { - "LOCALE", - details.SourceLocaleID + ": " + details.SourceLocaleDescription, - }, - {"STATUS", status}, - } - - for _, row := range info { - if _, err := fmt.Fprintf(tableWriter, "%s\t%s\n", row...); err != nil { - return err - } - } - - err = table.Render(tableWriter) - if err != nil { - return err - } - - return nil +// RunInfo retrieves and outputs project details, including the resolved +// local configuration. Returns an error if any. +func (s service) RunInfo(ctx context.Context) (projectconfig.Extended, error) { + return projectconfig.FetchExtendedConfig(ctx, s.Config, s.Client.GetProjectDetails) } diff --git a/services/projects/service.go b/services/projects/service.go index 105ada30..cde5cc71 100644 --- a/services/projects/service.go +++ b/services/projects/service.go @@ -4,13 +4,14 @@ import ( "context" "github.com/Smartling/smartling-cli/services/helpers/config" + projectconfig "github.com/Smartling/smartling-cli/services/projects/config" sdk "github.com/Smartling/api-sdk-go" ) // Service defines behavior for interacting with Smartling projects. type Service interface { - RunInfo(ctx context.Context) error + RunInfo(ctx context.Context) (projectconfig.Extended, error) RunList(ctx context.Context, short bool) error RunLocales(ctx context.Context, params LocalesParams) error } diff --git a/tests/cmd/projects/info/info_test.go b/tests/cmd/projects/info/info_test.go index c0be4ed1..968979b3 100644 --- a/tests/cmd/projects/info/info_test.go +++ b/tests/cmd/projects/info/info_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "os/exec" "strings" "testing" @@ -56,3 +57,40 @@ func TestProjectInfo_verbose(t *testing.T) { }) } } + +func TestProjectInfo_ShowConfig(t *testing.T) { + // `go test` runs without a TTY, so --show-config prints the banner to + // stdout and proceeds without prompting. + cmd := exec.Command("./../../bin/smartling-cli", "projects", "info", "--show-config") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + t.Fatalf("error: %v\nstderr:\n%s\nstdout:\n%s", err, stderr.String(), stdout.String()) + } + + out := stdout.String() + for _, want := range []string{ + "> Smartling CLI configuration:", + "> Config file:", + "> User ID:", + "> Account ID:", + "> Project ID:", + } { + if !strings.Contains(out, want) { + t.Errorf("stdout missing banner line %q\nfull stdout:\n%s", want, out) + } + } + + if strings.Contains(stderr.String(), "Continue?") { + t.Errorf("non-TTY run must not prompt:\n%s", stderr.String()) + } + + for _, want := range []string{"ID", "ACCOUNT", "NAME", "LOCALE", "STATUS", "USER", "CONFIG", "SOURCES"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing expected info row label %q\nfull stdout:\n%s", want, out) + } + } +}