From 725347ec48c98afc4be66f22af76154bce1be096 Mon Sep 17 00:00:00 2001 From: Roberto Hidalgo Date: Mon, 20 Mar 2023 22:18:09 -0600 Subject: [PATCH] error on unexpected arguments, test out logger, add .milpa courtesy of the department of departamental recursiveness --- .gitignore | 1 + .milpa/commands/dev/ci.sh | 6 + .milpa/commands/dev/ci.yaml | 3 + .milpa/commands/dev/lint.sh | 10 + .milpa/commands/dev/lint.yaml | 3 + .milpa/commands/dev/test.sh | 12 ++ .milpa/commands/dev/test.yaml | 7 + .milpa/commands/dev/test/coverage-report.sh | 18 ++ .milpa/commands/dev/test/coverage-report.yaml | 5 + .milpa/commands/dev/test/unit.sh | 17 ++ .milpa/commands/dev/test/unit.yaml | 7 + .milpa/commands/release/create.sh | 46 +++++ .milpa/commands/release/create.yaml | 15 ++ internal/commands/generate_completions.go | 5 +- internal/registry/cobra.go | 7 +- internal/registry/registry.go | 4 +- pkg/command/arguments.go | 13 +- pkg/command/arguments_test.go | 146 +------------ pkg/command/command.go | 4 +- pkg/command/options.go | 4 +- pkg/command/value.go | 2 +- pkg/command/value_test.go | 2 +- pkg/logger/formatter.go | 63 ++++-- pkg/logger/log_test.go | 187 +++++++++++++++++ pkg/runtime/runtime.go | 63 ++++-- pkg/runtime/runtime_test.go | 195 +++++++++++++++--- 26 files changed, 635 insertions(+), 210 deletions(-) create mode 100644 .gitignore create mode 100644 .milpa/commands/dev/ci.sh create mode 100644 .milpa/commands/dev/ci.yaml create mode 100644 .milpa/commands/dev/lint.sh create mode 100644 .milpa/commands/dev/lint.yaml create mode 100644 .milpa/commands/dev/test.sh create mode 100644 .milpa/commands/dev/test.yaml create mode 100644 .milpa/commands/dev/test/coverage-report.sh create mode 100644 .milpa/commands/dev/test/coverage-report.yaml create mode 100644 .milpa/commands/dev/test/unit.sh create mode 100644 .milpa/commands/dev/test/unit.yaml create mode 100644 .milpa/commands/release/create.sh create mode 100644 .milpa/commands/release/create.yaml create mode 100644 pkg/logger/log_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..612687a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage/* diff --git a/.milpa/commands/dev/ci.sh b/.milpa/commands/dev/ci.sh new file mode 100644 index 0000000..9bfb3e1 --- /dev/null +++ b/.milpa/commands/dev/ci.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright © 2022 Roberto Hidalgo + +milpa dev lint || @milpa.fail "linter has errors" +milpa dev test unit || @milpa.fail "tests failed" diff --git a/.milpa/commands/dev/ci.yaml b/.milpa/commands/dev/ci.yaml new file mode 100644 index 0000000..e2201a3 --- /dev/null +++ b/.milpa/commands/dev/ci.yaml @@ -0,0 +1,3 @@ +summary: Lints and tests milpa +description: | + runs `milpa dev lint {shell,go}` and `milpa dev test {unit,integration}` diff --git a/.milpa/commands/dev/lint.sh b/.milpa/commands/dev/lint.sh new file mode 100644 index 0000000..7bff90e --- /dev/null +++ b/.milpa/commands/dev/lint.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright © 2022 Roberto Hidalgo + +cd "$(dirname "$MILPA_COMMAND_REPO")" || @milpa.fail "could not cd into base dir" + +@milpa.log info "Linting go files" +golangci-lint run || exit 2 +@milpa.log complete "Go files are up to spec" + diff --git a/.milpa/commands/dev/lint.yaml b/.milpa/commands/dev/lint.yaml new file mode 100644 index 0000000..15ffae0 --- /dev/null +++ b/.milpa/commands/dev/lint.yaml @@ -0,0 +1,3 @@ +summary: Runs linter on go files +description: | + basically golangci-lint diff --git a/.milpa/commands/dev/test.sh b/.milpa/commands/dev/test.sh new file mode 100644 index 0000000..3820513 --- /dev/null +++ b/.milpa/commands/dev/test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright © 2022 Roberto Hidalgo + +set -e +if [[ "$MILPA_OPT_COVERAGE" ]]; then + rm -rf coverage + milpa dev test unit --coverage + milpa dev test coverage-report +else + milpa dev test unit +fi diff --git a/.milpa/commands/dev/test.yaml b/.milpa/commands/dev/test.yaml new file mode 100644 index 0000000..3733b73 --- /dev/null +++ b/.milpa/commands/dev/test.yaml @@ -0,0 +1,7 @@ +summary: Runs unit and integration tests +description: | + a wrapper for milpa dev test * +options: + coverage: + type: bool + description: if provided, will output coverage reports diff --git a/.milpa/commands/dev/test/coverage-report.sh b/.milpa/commands/dev/test/coverage-report.sh new file mode 100644 index 0000000..6213dfe --- /dev/null +++ b/.milpa/commands/dev/test/coverage-report.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright © 2022 Roberto Hidalgo + +runs=() +while IFS= read -r -d $'\0'; do + runs+=("$REPLY") +done < <(find coverage -type d -maxdepth 1 -mindepth 1 -print0) +packages="$(IFS=, ; echo "${runs[*]}")" + + +@milpa.log info "Building coverage report from runs: ${runs[*]}" +go tool covdata textfmt -i="$packages" -o coverage/coverage.cov || @milpa.fail "could not merge runs" +go tool cover -html=coverage/coverage.cov -o coverage/coverage.html || @milpa.fail "could not build reports" + +@milpa.log complete "Coverage report built" +go tool covdata percent -i="$packages" +go tool cover -func=coverage/coverage.cov | tail -n 1 diff --git a/.milpa/commands/dev/test/coverage-report.yaml b/.milpa/commands/dev/test/coverage-report.yaml new file mode 100644 index 0000000..8c7ee1f --- /dev/null +++ b/.milpa/commands/dev/test/coverage-report.yaml @@ -0,0 +1,5 @@ +summary: Creates a coverage report from previous test runs +description: | + looks at test/coverage/* for output + + see: https://go.dev/testing/coverage/ diff --git a/.milpa/commands/dev/test/unit.sh b/.milpa/commands/dev/test/unit.sh new file mode 100644 index 0000000..bfd9313 --- /dev/null +++ b/.milpa/commands/dev/test/unit.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright © 2022 Roberto Hidalgo + +root="$(dirname "$MILPA_COMMAND_REPO")" +cd "$root" || @milpa.fail "could not cd into $root" +@milpa.log info "Running unit tests" +args=() + +if [[ "${MILPA_OPT_COVERAGE}" ]]; then + cover_dir="$root/coverage/unit" + rm -rf "$cover_dir" + mkdir -p "$cover_dir" + args=( -test.gocoverdir="$cover_dir" --coverpkg=./... ) +fi +gotestsum --format short -- ./... "${args[@]}" || exit 2 +@milpa.log complete "Unit tests passed" diff --git a/.milpa/commands/dev/test/unit.yaml b/.milpa/commands/dev/test/unit.yaml new file mode 100644 index 0000000..e819227 --- /dev/null +++ b/.milpa/commands/dev/test/unit.yaml @@ -0,0 +1,7 @@ +summary: Runs unit tests +description: | + Runs unit tests using gotestsum +options: + coverage: + type: bool + description: if provided, will output coverage reports diff --git a/.milpa/commands/release/create.sh b/.milpa/commands/release/create.sh new file mode 100644 index 0000000..f798f0f --- /dev/null +++ b/.milpa/commands/release/create.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright © 2022 Roberto Hidalgo +@milpa.load_util user-input + +current_branch=$(git rev-parse --abbrev-ref HEAD) +[[ "$current_branch" != "main" ]] && @milpa.fail "Refusing to release on branch <$current_branch>" +[[ -n "$(git status --porcelain)" ]] && @milpa.fail "Git tree is messy, won't continue" + +function next_semver() { + local components + IFS="." read -r -a components <<< "${2}" + following="" + case "$1" in + major ) following="$((components[0]+1)).0.0" ;; + minor ) following="${components[0]}.$((components[1]+1)).0" ;; + patch ) following="${components[0]}.${components[1]}.$((components[2]+1))" ;; + *) @milpa.fail "unknown increment type: <$1>" + esac + + echo "$following" +} + +increment="$MILPA_ARG_INCREMENT" +# get the latest tag, ignoring any pre-releases +# by default current version is 0.0.-1, and must initially release a patch +current="$(git describe --abbrev=0 --exclude='*-*' --tags 2>/dev/null || echo "0.0.-1")" + +next=$(next_semver "$increment" "$current") + +if [[ "$MILPA_OPT_PRE" ]]; then + # pre releases might update previous ones, look for them + pre_current=$(git describe --abbrev=0 --match="$next-$MILPA_OPT_PRE.*" --tags 2>/dev/null || echo "$current-$MILPA_OPT_PRE.-1") + build=${pre_current##*.} + next="$next-$MILPA_OPT_PRE.$(( build + 1 ))" +fi + +@milpa.log info "Creating release with version $(@milpa.fmt inverted "$next")" +@milpa.confirm "Proceed with release?" || @milpa.fail "Refusing to continue, got <$REPLY>" +@milpa.log success "Continuing with release" + +@milpa.log info "Creating tag and pushing" +git tag "$next" || @milpa.fail "Could not create tag $next" +git push origin "$next" || @milpa.fail "Could not push tag $next" + +@milpa.log complete "Release created and pushed to origin!" diff --git a/.milpa/commands/release/create.yaml b/.milpa/commands/release/create.yaml new file mode 100644 index 0000000..bc770fa --- /dev/null +++ b/.milpa/commands/release/create.yaml @@ -0,0 +1,15 @@ +summary: Creates a new tag and updates the changelog +description: | + Automation might trigger a release if github is in a good mood +arguments: + - name: increment + description: "The kind of semver increment" + default: patch + values: + static: [major, minor, patch] + required: true +options: + pre: + values: + static: [alpha, beta, rc] + description: create a pre-release diff --git a/internal/commands/generate_completions.go b/internal/commands/generate_completions.go index eaef27a..463cf15 100644 --- a/internal/commands/generate_completions.go +++ b/internal/commands/generate_completions.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// Copyright © 2021 Roberto Hidalgo +// Copyright © 2022 Roberto Hidalgo package commands import ( @@ -14,7 +14,8 @@ var GenerateCompletions = &cobra.Command{ Hidden: true, DisableAutoGenTag: true, SilenceUsage: true, - Args: cobra.MinimumNArgs(1), + Args: cobra.ExactArgs(1), + ValidArgs: []string{"bash", "fish", "zsh"}, RunE: func(cmd *cobra.Command, args []string) (err error) { switch args[0] { case "bash": diff --git a/internal/registry/cobra.go b/internal/registry/cobra.go index 863f3f4..983e739 100644 --- a/internal/registry/cobra.go +++ b/internal/registry/cobra.go @@ -24,7 +24,8 @@ func newCobraRoot(root *command.Command) *cobra.Command { DisableAutoGenTag: true, SilenceUsage: true, SilenceErrors: true, - ValidArgs: []string{""}, + // This tricks cobra into erroring without a subcommand + ValidArgs: []string{""}, Args: func(cmd *cobra.Command, args []string) error { if err := cobra.OnlyValidArgs(cmd, args); err != nil { suggestions := []string{} @@ -73,7 +74,9 @@ func ToCobra(cmd *command.Command, globalOptions command.Options) *cobra.Command Args: func(cc *cobra.Command, supplied []string) error { skipValidation, _ := cc.Flags().GetBool("skip-validation") if !skipValidation && runtime.ValidationEnabled() { - cmd.Arguments.Parse(supplied) + if err := cmd.Arguments.Parse(supplied); err != nil { + return err + } return cmd.Arguments.AreValid() } return nil diff --git a/internal/registry/registry.go b/internal/registry/registry.go index f9dae08..52c5f62 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -91,7 +91,9 @@ func Execute(version string) error { if idx == len(cmd.Path)-1 { leaf := ToCobra(cmd, cmdRoot.Options) container.AddCommand(leaf) - container.ValidArgs = append(container.ValidArgs, leaf.Name()) + if container != ccRoot { + container.ValidArgs = append(container.ValidArgs, leaf.Name()) + } log.Tracef("cobra: %s => %s", leaf.Name(), container.CommandPath()) break } diff --git a/pkg/command/arguments.go b/pkg/command/arguments.go index 7dd8bcb..1e6ec93 100644 --- a/pkg/command/arguments.go +++ b/pkg/command/arguments.go @@ -51,7 +51,8 @@ func anySliceToStringSlice(src any) []string { return res } -func (args *Arguments) Parse(supplied []string) { +func (args *Arguments) Parse(supplied []string) error { + parsed := []string{} for idx, arg := range *args { argumentProvided := idx < len(supplied) @@ -76,7 +77,13 @@ func (args *Arguments) Parse(supplied []string) { } else { arg.SetValue([]string{supplied[idx]}) } + parsed = append(parsed, *arg.provided...) } + + if len(parsed) != len(supplied) { + return errors.BadArguments{Msg: fmt.Sprintf("Unexpected arguments provided: %s", supplied)} + } + return nil } func (args *Arguments) AreValid() error { @@ -106,7 +113,9 @@ func (args *Arguments) CompletionFunction(cc *cobra.Command, provided []string, lastArg := (*args)[len(*args)-1] hasVariadicArg := expectedArgLen > 0 && lastArg.Variadic lastArg.Command.Options.Parse(cc.Flags()) - args.Parse(provided) + if err := args.Parse(provided); err != nil { + return []string{err.Error()}, cobra.ShellCompDirectiveDefault + } directive = cobra.ShellCompDirectiveDefault if argsCompleted < expectedArgLen || hasVariadicArg { diff --git a/pkg/command/arguments_test.go b/pkg/command/arguments_test.go index d443982..7bf933b 100644 --- a/pkg/command/arguments_test.go +++ b/pkg/command/arguments_test.go @@ -39,7 +39,7 @@ func testCommand() *Command { func TestParse(t *testing.T) { cmd := testCommand() - cmd.Arguments.Parse([]string{"asdf", "one", "two", "three"}) + cmd.Arguments.Parse([]string{"asdf", "one", "two", "three"}) // nolint: errcheck known := cmd.Arguments.AllKnown() if !cmd.Arguments[0].IsKnown() { @@ -67,7 +67,7 @@ func TestParse(t *testing.T) { } cmd = testCommand() - cmd.Arguments.Parse([]string{"asdf"}) + cmd.Arguments.Parse([]string{"asdf"}) // nolint: errcheck known = cmd.Arguments.AllKnown() if !cmd.Arguments[0].IsKnown() { @@ -209,7 +209,7 @@ func TestArgumentsValidate(t *testing.T) { cmd.Arguments[1] = staticArgument("second", "", []string{"one", "two", "three"}, true) cmd.SetBindings() - cmd.Arguments.Parse([]string{"first", "one", "three", "two"}) + cmd.Arguments.Parse([]string{"first", "one", "three", "two"}) // nolint: errcheck err := cmd.Arguments.AreValid() if err == nil { @@ -219,7 +219,7 @@ func TestArgumentsValidate(t *testing.T) { for _, c := range cases { t.Run(c.Command.FullName(), func(t *testing.T) { - c.Command.Arguments.Parse(c.Args) + c.Command.Arguments.Parse(c.Args) // nolint: errcheck err := c.Command.Arguments.AreValid() if err == nil { @@ -232,144 +232,6 @@ func TestArgumentsValidate(t *testing.T) { } } -// func TestArgumentsToEnv(t *testing.T) { -// cases := []struct { -// Command *Command -// Args []string -// Expect []string -// Env []string -// }{ -// { -// Args: []string{"something"}, -// Expect: []string{"export MILPA_ARG_FIRST=something"}, -// Command: &Command{ -// // Name: []string{"test", "required", "present"}, -// Arguments: []*Argument{ -// { -// Name: "first", -// Required: true, -// }, -// }, -// }, -// }, -// { -// Args: []string{}, -// Expect: []string{"export MILPA_ARG_FIRST=default"}, -// Command: &Command{ -// // Name: []string{"test", "default", "present"}, -// Arguments: []*Argument{ -// { -// Name: "first", -// Default: "default", -// }, -// }, -// }, -// }, -// { -// Args: []string{"zero", "one", "two", "three"}, -// Expect: []string{ -// "export MILPA_ARG_FIRST=zero", -// "declare -a MILPA_ARG_VARIADIC='( one two three )'", -// }, -// Command: &Command{ -// // Name: []string{"test", "variadic"}, -// Arguments: []*Argument{ -// { -// Name: "first", -// Default: "default", -// }, -// { -// Name: "variadic", -// Variadic: true, -// }, -// }, -// }, -// }, -// { -// Args: []string{}, -// Expect: []string{"export MILPA_ARG_FIRST=default"}, -// Command: &Command{ -// // Name: []string{"test", "static", "default"}, -// Arguments: []*Argument{ -// { -// Name: "first", -// Default: "default", -// Values: &ValueSource{ -// Static: &[]string{ -// "default", -// "good", -// }, -// }, -// }, -// }, -// }, -// }, -// { -// Args: []string{"good"}, -// Expect: []string{"export MILPA_ARG_FIRST=good"}, -// Command: &Command{ -// // Name: []string{"test", "static", "good"}, -// Arguments: []*Argument{ -// { -// Name: "first", -// Default: "default", -// Values: &ValueSource{ -// Static: &[]string{ -// "default", -// "good", -// }, -// }, -// }, -// }, -// }, -// }, -// { -// Args: []string{"good"}, -// Expect: []string{"export MILPA_ARG_FIRST=good"}, -// Command: &Command{ -// // Name: []string{"test", "script", "good"}, -// Arguments: []*Argument{ -// { -// Name: "first", -// Default: "default", -// Values: &ValueSource{ -// Script: "echo good; echo default", -// }, -// }, -// }, -// }, -// }, -// } - -// for _, c := range cases { -// t.Run(c.Command.FullName(), func(t *testing.T) { -// dst := []string{} -// c.Command.SetBindings() -// c.Command.Arguments.Parse(c.Args) -// c.Command.Arguments.ToEnv(c.Command, &dst, "export ") - -// err := c.Command.Arguments.AreValid() -// if err != nil { -// t.Fatalf("Unexpected failure validating: %s", err) -// } - -// for _, expected := range c.Expect { -// found := false -// for _, actual := range dst { -// if strings.HasPrefix(actual, expected) { -// found = true -// break -// } -// } - -// if !found { -// t.Fatalf("Expected line %v not found in %v", expected, dst) -// } -// } -// }) -// } -// } - func TestArgumentToDesc(t *testing.T) { cases := []struct { Arg *Argument diff --git a/pkg/command/command.go b/pkg/command/command.go index e83728b..19c7d45 100644 --- a/pkg/command/command.go +++ b/pkg/command/command.go @@ -119,7 +119,9 @@ func (cmd *Command) FlagSet() *pflag.FlagSet { } func (cmd *Command) ParseInput(cc *cobra.Command, args []string) error { - cmd.Arguments.Parse(args) + if err := cmd.Arguments.Parse(args); err != nil { + return err + } skipValidation, _ := cc.Flags().GetBool("skip-validation") cmd.Options.Parse(cc.Flags()) if !skipValidation { diff --git a/pkg/command/options.go b/pkg/command/options.go index f6aede0..ace97bc 100644 --- a/pkg/command/options.go +++ b/pkg/command/options.go @@ -165,7 +165,9 @@ func (opt *Option) CompletionFunction(cmd *cobra.Command, args []string, toCompl return } - opt.Command.Arguments.Parse(args) + if err := opt.Command.Arguments.Parse(args); err != nil { + return []string{err.Error()}, cobra.ShellCompDirectiveDefault + } opt.Command.Options.Parse(cmd.Flags()) var err error diff --git a/pkg/command/value.go b/pkg/command/value.go index afc9a22..34dc10a 100644 --- a/pkg/command/value.go +++ b/pkg/command/value.go @@ -311,7 +311,7 @@ func (vs *ValueSource) UnmarshalYAML(node *yaml.Node) error { var customCompleters = map[string]CompletionFunc{} -// Registers a completion function for the given command.ValueType key name +// Registers a completion function for the given command.ValueType key name. func RegisterValueSource(key string, completion CompletionFunc) { customCompleters[key] = completion } diff --git a/pkg/command/value_test.go b/pkg/command/value_test.go index ca3c2d9..ebeb478 100644 --- a/pkg/command/value_test.go +++ b/pkg/command/value_test.go @@ -138,7 +138,7 @@ func TestResolveTemplate(t *testing.T) { }, }, }).SetBindings() - cmd.Arguments.Parse(test.Args) + cmd.Arguments.Parse(test.Args) // nolint: errcheck cmd.Options.Parse(test.Flags) res, err := cmd.ResolveTemplate(test.Tpl, "") diff --git a/pkg/logger/formatter.go b/pkg/logger/formatter.go index 115c04d..42c9ae8 100644 --- a/pkg/logger/formatter.go +++ b/pkg/logger/formatter.go @@ -1,14 +1,38 @@ +// Copyright © 2022 Roberto Hidalgo +// SPDX-License-Identifier: Apache-2.0 package logger import ( - "fmt" "strings" "time" "git.rob.mx/nidito/chinampa/pkg/runtime" + "github.com/fatih/color" "github.com/sirupsen/logrus" ) +var bold *color.Color +var boldRedBG *color.Color +var boldRed *color.Color +var boldYellowBG *color.Color +var boldYellow *color.Color +var dimmed *color.Color + +func init() { + bold = color.New(color.Bold) + bold.EnableColor() + boldRedBG = color.New(color.Bold, color.BgRed) + boldRedBG.EnableColor() + boldRed = color.New(color.Bold, color.FgHiRed) + boldRed.EnableColor() + boldYellowBG = color.New(color.Bold, color.BgYellow, color.FgBlack) + boldYellowBG.EnableColor() + boldYellow = color.New(color.Bold, color.FgHiYellow) + boldYellow.EnableColor() + dimmed = color.New(color.Faint) + dimmed.EnableColor() +} + type Formatter struct { } @@ -16,7 +40,8 @@ func (f *Formatter) Format(entry *logrus.Entry) ([]byte, error) { prefix := "" colorEnabled := runtime.ColorEnabled() message := entry.Message - if runtime.VerboseEnabled() { + switch { + case runtime.VerboseEnabled(): date := strings.Replace(entry.Time.Local().Format(time.DateTime), " ", "T", 1) component := "" if c, ok := entry.Data[componentKey]; ok { @@ -24,36 +49,40 @@ func (f *Formatter) Format(entry *logrus.Entry) ([]byte, error) { } level := entry.Level.String() if colorEnabled { - if entry.Level <= logrus.ErrorLevel { - level = "\033[31m\033[1m" + level + "\033[0m" - } else if entry.Level == logrus.WarnLevel { - level = "\033[33m\033[1m" + level + "\033[0m" - } else if entry.Level >= logrus.DebugLevel && colorEnabled { - message = "\033[2m" + message + "\033[0m" + switch { + case entry.Level <= logrus.ErrorLevel: + level = boldRed.Sprint(level) + case entry.Level == logrus.WarnLevel: + level = boldYellow.Sprint(level) + case entry.Level >= logrus.DebugLevel: + level = dimmed.Sprint(level) + message = dimmed.Sprint(message) + default: + level = dimmed.Sprint(level) } } - prefix = fmt.Sprintf("\033[2m%s %s%s\033[0m\t", date, level, component) - } else if entry.Level == logrus.ErrorLevel { + prefix = dimmed.Sprint(date) + " " + level + dimmed.Sprint(component) + "\t" + case entry.Level == logrus.ErrorLevel: if colorEnabled { - prefix = "\033[41m\033[1m ERROR \033[0m " + prefix = boldRedBG.Sprint(" ERROR ") + " " } else { prefix = "ERROR: " } - } else if entry.Level == logrus.WarnLevel { + case entry.Level == logrus.WarnLevel: if colorEnabled { - prefix = "\033[43m\033[31m warning \033[0m " - message = "\033[33m" + message + "\033[0m" + prefix = boldYellowBG.Sprint(" WARNING ") + " " } else { prefix = "WARNING: " } - } else if entry.Level >= logrus.DebugLevel { + case entry.Level >= logrus.DebugLevel: if colorEnabled { - prefix = "\033[2m" + entry.Level.String() + ":\033[0m " - message = "\033[2m" + message + "\033[0m" + prefix = dimmed.Sprintf("%s: ", strings.ToUpper(entry.Level.String())) + message = dimmed.Sprint(message) } else { prefix = strings.ToUpper(entry.Level.String()) + ": " } } + return []byte(prefix + message + "\n"), nil } diff --git a/pkg/logger/log_test.go b/pkg/logger/log_test.go new file mode 100644 index 0000000..c7378bf --- /dev/null +++ b/pkg/logger/log_test.go @@ -0,0 +1,187 @@ +// Copyright © 2022 Roberto Hidalgo +// SPDX-License-Identifier: Apache-2.0 +package logger_test + +import ( + "bytes" + "fmt" + "os" + "reflect" + "runtime" + "strings" + "testing" + "time" + + . "git.rob.mx/nidito/chinampa/pkg/logger" + rt "git.rob.mx/nidito/chinampa/pkg/runtime" + "github.com/sirupsen/logrus" +) + +func withEnv(t *testing.T, env map[string]string) { + prevEnv := os.Environ() + for _, entry := range prevEnv { + parts := strings.SplitN(entry, "=", 2) + os.Unsetenv(parts[0]) + } + + for k, v := range env { + os.Setenv(k, v) + } + + t.Cleanup(func() { + rt.ResetParsedFlags() + + for k := range env { + os.Unsetenv(k) + } + for _, entry := range prevEnv { + parts := strings.SplitN(entry, "=", 2) + os.Setenv(parts[0], parts[1]) + } + }) +} + +func TestFormatter(t *testing.T) { + now := strings.Replace(time.Now().Local().Format(time.DateTime), " ", "T", 1) + cases := []struct { + Color bool + Verbose bool + Call func(args ...any) + Expects string + Level logrus.Level + }{ + { + Color: true, + Call: Info, + Expects: "message", + Level: logrus.InfoLevel, + }, + { + Color: true, + Verbose: true, + Call: Info, + Expects: fmt.Sprintf("\033[2m%s\033[0m \033[2minfo\033[0m\033[2m\033[0m message", now), + Level: logrus.InfoLevel, + }, + { + Color: true, + Call: Debug, + Expects: "", + Level: logrus.InfoLevel, + }, + { + Call: Debug, + Expects: "DEBUG: message", + Level: logrus.DebugLevel, + }, + { + Color: true, + Call: Debug, + Expects: "\033[2mDEBUG: \033[0m\033[2mmessage\033[0m", + Level: logrus.DebugLevel, + }, + { + Color: true, + Verbose: true, + Call: Debug, + Expects: fmt.Sprintf("\033[2m%s\033[0m \033[2mdebug\033[0m\033[2m\033[0m\t\033[2mmessage\033[0m", + now), + Level: logrus.DebugLevel, + }, + { + Call: Trace, + Expects: "", + Level: logrus.DebugLevel, + }, + { + Call: Trace, + Expects: "TRACE: message", + Level: logrus.TraceLevel, + }, + { + Call: Warn, + Expects: "", + Level: logrus.ErrorLevel, + }, + { + Call: Warn, + Expects: "WARNING: message", + Level: logrus.InfoLevel, + }, + { + Call: Warn, + Level: logrus.InfoLevel, + Color: true, + Verbose: true, + Expects: fmt.Sprintf("\033[2m%s\033[0m \033[1;93mwarning\033[0m\033[2m\033[0m\tmessage", now), + }, + { + Call: Warn, + Level: logrus.InfoLevel, + Color: true, + Expects: "\033[1;43;30m WARNING \033[0m message", + }, + { + Call: Error, + Expects: "ERROR: message", + Level: logrus.ErrorLevel, + }, + { + Call: Error, + Expects: "ERROR: message", + Level: logrus.InfoLevel, + }, + { + Call: Error, + Level: logrus.InfoLevel, + Color: true, + Verbose: true, + Expects: fmt.Sprintf("\033[2m%s\033[0m \033[1;91merror\033[0m\033[2m\033[0m\tmessage", now), + }, + { + Call: Error, + Level: logrus.InfoLevel, + Color: true, + Expects: "\033[1;41m ERROR \033[0m message", + }, + } + + for _, c := range cases { + fname := runtime.FuncForPC(reflect.ValueOf(c.Call).Pointer()).Name() + comps := []string{fname, c.Level.String()} + if c.Color { + comps = append(comps, "color") + } + if c.Verbose { + comps = append(comps, "verbose") + } + name := strings.Join(comps, "/") + t.Run(name, func(t *testing.T) { + env := map[string]string{ + "COLOR": "", + "VERBOSE": "", + } + if c.Color { + env["COLOR"] = "always" + } else { + env["NO_COLOR"] = "1" + } + if c.Verbose { + env["VERBOSE"] = "1" + } + withEnv(t, env) + data := bytes.Buffer{} + logrus.SetLevel(c.Level) + logrus.SetOutput(&data) + c.Call("message") + expected := c.Expects + if c.Expects != "" { + expected = c.Expects + "\n" + } + + if res := data.String(); res != expected { + t.Fatalf("%s:\ngot : %s\nwanted: %v", name, res, expected) + } + }) + } +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 92f4a55..873bb61 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -52,39 +52,72 @@ func isTrueIsh(val string) bool { return false } +var _flags map[string]bool + +func ResetParsedFlags() { + _flags = nil +} + +func flagInArgs(name string) bool { + if _flags == nil { + _flags = map[string]bool{} + for _, arg := range os.Args { + switch arg { + case "--verbose": + _flags["verbose"] = true + delete(_flags, "silent") + case "--silent": + _flags["silent"] = true + delete(_flags, "verbose") + case "--color": + _flags["color"] = true + delete(_flags, "no-color") + case "--no-color": + _flags["no-color"] = true + delete(_flags, "color") + case "--skip-validation": + _flags["skip-validation"] = true + } + } + } + + _, ok := _flags[name] + return ok +} + func DebugEnabled() bool { return isTrueIsh(os.Getenv(env.Debug)) } func ValidationEnabled() bool { - return isFalseIsh(os.Getenv(env.ValidationDisabled)) + return !flagInArgs("skip-validation") && isFalseIsh(os.Getenv(env.ValidationDisabled)) } func VerboseEnabled() bool { - for _, arg := range os.Args { - if arg == "--verbose" { - return true - } + if flagInArgs("silent") { + return false } - return isTrueIsh(os.Getenv(env.Verbose)) + return isTrueIsh(os.Getenv(env.Verbose)) || flagInArgs("verbose") } func SilenceEnabled() bool { - for _, arg := range os.Args { - if arg == "--silent" { - return true - } - } - - if VerboseEnabled() { + if flagInArgs("verbose") { return false } + if flagInArgs("silent") { + return true + } - return isTrueIsh(os.Getenv(env.Silent)) + return isTrueIsh(os.Getenv(env.Silent)) || flagInArgs("silent") } func ColorEnabled() bool { - return isFalseIsh(os.Getenv(env.NoColor)) && !UnstyledHelpEnabled() + if flagInArgs("color") { + return true + } + + // we're talking to ttys, we want color unless NO_COLOR/--no-color + return !(isTrueIsh(os.Getenv(env.NoColor)) || UnstyledHelpEnabled() || flagInArgs("no-color")) } func UnstyledHelpEnabled() bool { diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index ad90f4d..f3b60f8 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -8,15 +8,146 @@ import ( "reflect" "runtime" "strconv" + "strings" "testing" "git.rob.mx/nidito/chinampa/pkg/env" . "git.rob.mx/nidito/chinampa/pkg/runtime" ) -func TestEnabled(t *testing.T) { - defer func() { os.Setenv(env.Verbose, "") }() +func withEnv(t *testing.T, env map[string]string) { + prevEnv := os.Environ() + for _, entry := range prevEnv { + parts := strings.SplitN(entry, "=", 2) + os.Unsetenv(parts[0]) + } + for k, v := range env { + os.Setenv(k, v) + } + + t.Cleanup(func() { + ResetParsedFlags() + + for k := range env { + os.Unsetenv(k) + } + for _, entry := range prevEnv { + parts := strings.SplitN(entry, "=", 2) + os.Setenv(parts[0], parts[1]) + } + }) +} + +func TestCombinations(t *testing.T) { + args := append([]string{}, os.Args...) + t.Cleanup(func() { os.Args = args }) + cases := []struct { + Env map[string]string + Args []string + Func func() bool + Expects bool + }{ + { + Env: map[string]string{}, + Args: []string{}, + Func: VerboseEnabled, + Expects: false, + }, + { + Env: map[string]string{env.Verbose: "1"}, + Args: []string{"--silent"}, + Func: VerboseEnabled, + Expects: false, + }, + { + Env: map[string]string{env.Verbose: "1"}, + Args: []string{}, + Func: VerboseEnabled, + Expects: true, + }, + { + Env: map[string]string{env.Silent: "1"}, + Args: []string{}, + Func: VerboseEnabled, + Expects: false, + }, + { + Env: map[string]string{}, + Args: []string{}, + Func: SilenceEnabled, + Expects: false, + }, + { + Env: map[string]string{env.Silent: "1"}, + Args: []string{}, + Func: SilenceEnabled, + Expects: true, + }, + { + Env: map[string]string{env.Silent: "1"}, + Args: []string{"--verbose"}, + Func: SilenceEnabled, + Expects: false, + }, + { + Env: map[string]string{env.Verbose: "1"}, + Args: []string{"--silent"}, + Func: SilenceEnabled, + Expects: true, + }, + { + Env: map[string]string{env.ForceColor: "1"}, + Args: []string{"--no-color"}, + Func: ColorEnabled, + Expects: false, + }, + { + Env: map[string]string{}, + Args: []string{}, + Func: ColorEnabled, + Expects: true, + }, + { + Env: map[string]string{env.ForceColor: "1"}, + Args: []string{}, + Func: ColorEnabled, + Expects: true, + }, + { + Env: map[string]string{env.ForceColor: "1"}, + Args: []string{"--no-color"}, + Func: ColorEnabled, + Expects: false, + }, + { + Env: map[string]string{env.NoColor: "1"}, + Args: []string{}, + Func: ColorEnabled, + Expects: false, + }, + { + Env: map[string]string{env.NoColor: "1"}, + Args: []string{"--color"}, + Func: ColorEnabled, + Expects: true, + }, + } + + for _, c := range cases { + fname := runtime.FuncForPC(reflect.ValueOf(c.Func).Pointer()).Name() + name := fmt.Sprintf("%v/%v/%s", fname, c.Env, c.Args) + t.Run(name, func(t *testing.T) { + withEnv(t, c.Env) + os.Args = c.Args + if res := c.Func(); res != c.Expects { + t.Fatalf("%s got %v wanted: %v", name, res, c.Expects) + } + }) + } +} + +func TestEnabled(t *testing.T) { cases := []struct { Name string Func func() bool @@ -27,6 +158,7 @@ func TestEnabled(t *testing.T) { Func: VerboseEnabled, Expects: true, }, + { Name: env.Silent, Func: SilenceEnabled, @@ -64,7 +196,7 @@ func TestEnabled(t *testing.T) { } for _, val := range enabled { t.Run("enabled-"+val, func(t *testing.T) { - os.Setenv(c.Name, val) + withEnv(t, map[string]string{c.Name: val}) if c.Func() != c.Expects { t.Fatalf("%s wasn't enabled with a valid value: %s", name, val) } @@ -74,7 +206,7 @@ func TestEnabled(t *testing.T) { disabled := []string{"", "no", "false", "0", "disabled"} for _, val := range disabled { t.Run("disabled-"+val, func(t *testing.T) { - os.Setenv(c.Name, val) + withEnv(t, map[string]string{c.Name: val}) if c.Func() == c.Expects { t.Fatalf("%s was enabled with falsy value: %s", name, val) } @@ -84,40 +216,46 @@ func TestEnabled(t *testing.T) { } func TestSilent(t *testing.T) { - origArgs := os.Args - t.Cleanup(func() { - os.Args = origArgs - }) + args := append([]string{}, os.Args...) + t.Cleanup(func() { os.Args = args }) t.Run("SILENT = silence", func(t *testing.T) { - t.Setenv(env.Silent, "1") - t.Setenv(env.Verbose, "") + withEnv(t, map[string]string{ + env.Silent: "1", + env.Verbose: "", + }) os.Args = []string{} if !SilenceEnabled() { t.Fail() } }) - t.Run("SILENT + VERBOSE = silence", func(t *testing.T) { - t.Setenv(env.Silent, "1") - t.Setenv(env.Verbose, "1") + t.Run("SILENT+VERBOSE=silence", func(t *testing.T) { + withEnv(t, map[string]string{ + env.Silent: "1", + env.Verbose: "1", + }) os.Args = []string{} - if SilenceEnabled() { + if !SilenceEnabled() { t.Fail() } }) - t.Run("VERBOSE + --silent = silent", func(t *testing.T) { - t.Setenv(env.Silent, "") - t.Setenv(env.Verbose, "1") + t.Run("VERBOSE+--silent=silent", func(t *testing.T) { + withEnv(t, map[string]string{ + env.Silent: "0", + env.Verbose: "1", + }) os.Args = []string{"some", "random", "--silent", "args"} if !SilenceEnabled() { t.Fail() } }) - t.Run("--silent = silent", func(t *testing.T) { - t.Setenv(env.Silent, "") - t.Setenv(env.Verbose, "") + t.Run("--silent=silent", func(t *testing.T) { + withEnv(t, map[string]string{ + env.Silent: "", + env.Verbose: "", + }) os.Args = []string{"some", "random", "--silent", "args"} if !SilenceEnabled() { t.Fail() @@ -125,20 +263,27 @@ func TestSilent(t *testing.T) { }) t.Run("nothing = nothing", func(t *testing.T) { - t.Setenv(env.Silent, "") - t.Setenv(env.Verbose, "") + withEnv(t, map[string]string{ + env.Silent: "", + env.Verbose: "", + }) os.Args = []string{"some", "random", "args"} if SilenceEnabled() { t.Fail() } + if VerboseEnabled() { + t.Fail() + } }) } func TestEnvironmentMapEnabled(t *testing.T) { trueString := strconv.FormatBool(true) - os.Setenv(env.ForceColor, trueString) - os.Setenv(env.Debug, trueString) - os.Setenv(env.Verbose, trueString) + withEnv(t, map[string]string{ + env.ForceColor: trueString, + env.Debug: trueString, + env.Verbose: trueString, + }) res := EnvironmentMap() if res == nil {