Repository: gruntwork-io/terragrunt (language: go)

Issue:
`--terragrunt-log-custom-format` new replacement to only show the terraform command
Is there a value I can specify for `--terragrunt-log-custom-format` to only show the terraform command from `%tf-command-args` rather than the command with all the args? For example, `%tf-command-args` is currently expanding to `init -reconfigure -backend=false` but I'd rather just see `init` to know what stage of my init/plan/apply lifecycle the command is executing (I dont need to see `-reconfigure -backend=false` in the log prefix). Maybe a new replacement named `%tf-command` would be easier to use for this?


Discussion / hints:

Fix patch:
diff --git a/docs/_docs/04_reference/log-formatting.md b/docs/_docs/04_reference/log-formatting.md
index afc398e79b..0403204116 100644
--- a/docs/_docs/04_reference/log-formatting.md
+++ b/docs/_docs/04_reference/log-formatting.md
@@ -60,13 +60,15 @@ Placeholders have preset names:
 
 * `%tf-path` - Path to the OpenTofu/Terraform executable (as defined by [terragrunt-tfpath](https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-tfpath)).
 
-* `%tf-command-args` - Arguments of the executed OpenTofu/Terraform command.
+* `%tf-command` - Executed OpenTofu/Terraform command, e.g. `apply`.
 
-* `%t` - Tab.
+* `%tf-command-args` - Arguments of the executed OpenTofu/Terraform command, e.g. `apply -auto-approve`.
+
+* `%t` - Indent.
 
 * `%n` - Newline.
 
-Any other text is considered plain text.
+Any other text is considered plain text. The parser always tries to find the longest name. For example, tofu command "apply -auto-approve" with format "%tf-command-args" will be replaced with "apply -auto-approve", but not "apply-args". If you need to replace it with "apply-args", use empty brackets "%tf-command()-args". More examples: "%tf-path" will be replaced with "tofu", `%t()-path` will be replaced with "   -path".
 
 e.g.
 
diff --git a/pkg/log/format/options/color.go b/pkg/log/format/options/color.go
index 1e88fce218..0d778d683f 100644
--- a/pkg/log/format/options/color.go
+++ b/pkg/log/format/options/color.go
@@ -3,6 +3,7 @@ package options
 import (
 	"strconv"
 	"strings"
+	"sync"
 
 	"github.com/gruntwork-io/terragrunt/internal/errors"
 	"github.com/gruntwork-io/terragrunt/pkg/log"
@@ -212,6 +213,7 @@ type gradientColor struct {
 	// We use [xsync.MapOf](https://github.com/puzpuzpuz/xsync?tab=readme-ov-file#map) instead of standard `sync.Map` since it's faster and has generic types.
 	cache  *xsync.MapOf[string, ColorValue]
 	values []ColorValue
+	mu     sync.Mutex
 
 	// nextStyleIndex is used to get the next style from the `codes` list for a newly discovered text.
 	nextStyleIndex int
@@ -225,6 +227,9 @@ func newGradientColor() *gradientColor {
 }
 
 func (color *gradientColor) Value(text string) ColorValue {
+	color.mu.Lock()
+	defer color.mu.Unlock()
+
 	if colorCode, ok := color.cache.Load(text); ok {
 		return colorCode
 	}
diff --git a/pkg/log/format/options/common.go b/pkg/log/format/options/common.go
index f9dbb8deae..32351da7bb 100644
--- a/pkg/log/format/options/common.go
+++ b/pkg/log/format/options/common.go
@@ -2,6 +2,7 @@ package options
 
 import (
 	"fmt"
+	"sort"
 	"strconv"
 	"strings"
 
@@ -110,7 +111,10 @@ func (val *MapValue[T]) Parse(str string) error {
 		}
 	}
 
-	return errors.Errorf("available values: %s", strings.Join(maps.Values(val.list), ","))
+	list := maps.Values(val.list)
+	sort.Strings(list)
+
+	return errors.Errorf("available values: %s", strings.Join(list, ","))
 }
 
 func (val *MapValue[T]) Filter(vals ...T) MapValue[T] {
diff --git a/pkg/log/format/options/errors.go b/pkg/log/format/options/errors.go
new file mode 100644
index 0000000000..1b284ab54c
--- /dev/null
+++ b/pkg/log/format/options/errors.go
@@ -0,0 +1,80 @@
+package options
+
+import (
+	"fmt"
+	"strings"
+)
+
+// InvalidOptionError is an invalid `option` syntax error.
+type InvalidOptionError struct {
+	str string
+}
+
+// NewInvalidOptionError returns a new `InvalidOptionError` instance.
+func NewInvalidOptionError(str string) *InvalidOptionError {
+	return &InvalidOptionError{
+		str: str,
+	}
+}
+
+func (err InvalidOptionError) Error() string {
+	return fmt.Sprintf("invalid option syntax %q", err.str)
+}
+
+// EmptyOptionNameError is an empty `option` name error.
+type EmptyOptionNameError struct {
+	str string
+}
+
+// NewEmptyOptionNameError returns a new `EmptyOptionNameError` instance.
+func NewEmptyOptionNameError(str string) *EmptyOptionNameError {
+	return &EmptyOptionNameError{
+		str: str,
+	}
+}
+
+func (err EmptyOptionNameError) Error() string {
+	return fmt.Sprintf("empty option name %q", err.str)
+}
+
+// InvalidOptionNameError is an invalid `option` name error.
+type InvalidOptionNameError struct {
+	name string
+	opts Options
+}
+
+// NewInvalidOptionNameError returns a new `InvalidOptionNameError` instance.
+func NewInvalidOptionNameError(name string, opts Options) *InvalidOptionNameError {
+	return &InvalidOptionNameError{
+		name: name,
+		opts: opts,
+	}
+}
+
+func (err InvalidOptionNameError) Error() string {
+	return fmt.Sprintf("invalid option name %q, available names: %s", err.name, strings.Join(err.opts.Names(), ","))
+}
+
+// InvalidOptionValueError is an invalid `option` value error.
+type InvalidOptionValueError struct {
+	val string
+	opt Option
+	err error
+}
+
+// NewInvalidOptionValueError returns a new `InvalidOptionValueError` instance.
+func NewInvalidOptionValueError(opt Option, val string, err error) *InvalidOptionValueError {
+	return &InvalidOptionValueError{
+		val: val,
+		opt: opt,
+		err: err,
+	}
+}
+
+func (err InvalidOptionValueError) Error() string {
+	return fmt.Sprintf("option %q, invalid value %q, %v", err.opt.Name(), err.val, err.err)
+}
+
+func (err InvalidOptionValueError) Unwrap() error {
+	return err.err
+}
diff --git a/pkg/log/format/options/option.go b/pkg/log/format/options/option.go
index 8ded8a60e4..3fac367f64 100644
--- a/pkg/log/format/options/option.go
+++ b/pkg/log/format/options/option.go
@@ -3,13 +3,26 @@ package options
 
 import (
 	"reflect"
+	"strings"
+	"unicode"
 
+	"github.com/gruntwork-io/terragrunt/internal/errors"
 	"github.com/gruntwork-io/terragrunt/pkg/log"
 )
 
+// Constants for parsing options.
+const (
+	OptNameValueSep = "="
+	OptSep          = ","
+	OptStartSign    = "("
+	OptEndSign      = ")"
+)
+
+const splitIntoNameAndValue = 2
+
 // OptionValue contains the value of the option.
 type OptionValue[T any] interface {
-	// Parse parses and sets the value of the option.
+	// Configure parses and sets the value of the option.
 	Parse(str string) error
 	// Get returns the value of the option.
 	Get() T
@@ -20,7 +33,7 @@ type Option interface {
 	// Name returns the name of the option.
 	Name() string
 	// Format formats the given string.
-	Format(data *Data, val any) (any, error)
+	Format(data *Data, str any) (any, error)
 	// ParseValue parses and sets the value of the option.
 	ParseValue(str string) error
 }
@@ -76,15 +89,132 @@ func (opts Options) Merge(withOpts ...Option) Options {
 }
 
 // Format returns the formatted value.
-func (opts Options) Format(data *Data, val any) (string, error) {
+func (opts Options) Format(data *Data, str any) (string, error) {
 	var err error
 
 	for _, opt := range opts {
-		val, err = opt.Format(data, val)
-		if val == "" || err != nil {
+		str, err = opt.Format(data, str)
+		if str == "" || err != nil {
 			return "", err
 		}
 	}
 
-	return toString(val), nil
+	return toString(str), nil
+}
+
+// Configure parsers the given `str` to configure the `opts` and returns the rest of the given `str`.
+//
+// e.g. (color=green, case=upper) some-text" sets `color` option to `green`, `case` option to `upper` and returns " some-text".
+func (opts Options) Configure(str string) (string, error) {
+	if len(str) == 0 || !strings.HasPrefix(str, OptStartSign) {
+		return str, nil
+	}
+
+	str = str[1:]
+
+	for {
+		var (
+			ok  bool
+			err error
+		)
+
+		if str, ok = nextOption(str); !ok {
+			return str, nil
+		}
+
+		parts := strings.SplitN(str, OptNameValueSep, splitIntoNameAndValue)
+		if len(parts) != splitIntoNameAndValue {
+			return "", errors.New(NewInvalidOptionError(str))
+		}
+
+		name := strings.TrimSpace(parts[0])
+
+		if name == "" {
+			return "", errors.New(NewEmptyOptionNameError(str))
+		}
+
+		opt := opts.Get(name)
+		if opt == nil {
+			return "", errors.New(NewInvalidOptionNameError(name, opts))
+		}
+
+		if str, err = setOptionValue(opt, parts[1]); err != nil {
+			return "", err
+		}
+	}
+}
+
+// setOptionValue parses the given `str` and sets the value for the given `opt` and returns the rest of the given `str`.
+//
+// e.g. "green, case=upper) some-text" sets "green" to the option and returns ", case=upper) some-text".
+// e.g. "' quoted value ') some-text" sets " quoted value " to the option and returns ") some-text".
+func setOptionValue(opt Option, str string) (string, error) {
+	var quoteChar byte
+
+	for index := range str {
+		if quoteOpened(str[:index], &quoteChar) {
+			continue
+		}
+
+		lastSign := str[index : index+1]
+		if !strings.HasSuffix(lastSign, OptSep) && !strings.HasSuffix(lastSign, OptEndSign) {
+			continue
+		}
+
+		val := strings.TrimSpace(str[:index])
+		val = strings.Trim(val, "'")
+		val = strings.Trim(val, "\"")
+
+		if err := opt.ParseValue(val); err != nil {
+			return "", errors.New(NewInvalidOptionValueError(opt, val, err))
+		}
+
+		return str[index:], nil
+	}
+
+	return str, nil
+}
+
+// nextOption returns true if the given `str` contains one more option
+// and returns the given `str` without separator sign "," or ")".
+//
+// e.g. ",color=green) some-text" returns "color=green) some-text" and `true`.
+// e.g. "(color=green) some-text" returns "color=green) some-text" and `true`.
+// e.g. ") some-text"  returns " some-text" and `false`.
+func nextOption(str string) (string, bool) {
+	str = strings.TrimLeftFunc(str, unicode.IsSpace)
+
+	switch {
+	case strings.HasPrefix(str, OptEndSign):
+		return str[1:], false
+	case strings.HasPrefix(str, OptSep):
+		return str[1:], true
+	}
+
+	return str, true
+}
+
+// quoteOpened returns true if the given `str` contains an unclosed quote.
+//
+// e.g. "%(content=' level" return `true`.
+// e.g. "%(content=' level '" return `false`.
+// e.g. "%(content=\" level" return `true`.
+func quoteOpened(str string, quoteChar *byte) bool {
+	strlen := len(str)
+
+	if strlen == 0 {
+		return false
+	}
+
+	char := str[strlen-1]
+
+	if char == '"' || char == '\'' {
+		if *quoteChar == 0 {
+			*quoteChar = char
+		} else if *quoteChar == char && (strlen < 2 || str[strlen-2] != '\\') {
+			*quoteChar = 0
+		}
+	}
+
+	return *quoteChar != 0
 }
diff --git a/pkg/log/format/placeholders/common.go b/pkg/log/format/placeholders/common.go
index 8b5a5afb3b..017ea82b5e 100644
--- a/pkg/log/format/placeholders/common.go
+++ b/pkg/log/format/placeholders/common.go
@@ -1,9 +1,6 @@
 package placeholders
 
 import (
-	"strings"
-
-	"github.com/gruntwork-io/terragrunt/internal/errors"
 	"github.com/gruntwork-io/terragrunt/pkg/log/format/options"
 )
 
@@ -39,13 +36,9 @@ func (common *CommonPlaceholder) Name() string {
 	return common.name
 }
 
-// GetOption implements `Placeholder` interface.
-func (common *CommonPlaceholder) GetOption(str string) (options.Option, error) {
-	if opt := common.opts.Get(str); opt != nil {
-		return opt, nil
-	}
-
-	return nil, errors.Errorf("available values: %s", strings.Join(common.opts.Names(), ","))
+// Options implements `Placeholder` interface.
+func (common *CommonPlaceholder) Options() options.Options {
+	return common.opts
 }
 
 // Format implements `Placeholder` interface.
diff --git a/pkg/log/format/placeholders/errors.go b/pkg/log/format/placeholders/errors.go
new file mode 100644
index 0000000000..681765f7f2
--- /dev/null
+++ b/pkg/log/format/placeholders/errors.go
@@ -0,0 +1,56 @@
+package placeholders
+
+import (
+	"fmt"
+	"strings"
+)
+
+// InvalidPlaceholderNameError is an invalid `placeholder` name error.
+type InvalidPlaceholderNameError struct {
+	str  string
+	opts Placeholders
+}
+
+// NewInvalidPlaceholderNameError returns a new `InvalidPlaceholderNameError` instance.
+func NewInvalidPlaceholderNameError(str string, opts Placeholders) *InvalidPlaceholderNameError {
+	return &InvalidPlaceholderNameError{
+		str:  str,
+		opts: opts,
+	}
+}
+
+func (err InvalidPlaceholderNameError) Error() string {
+	var name string
+
+	for index := range len(err.str) {
+		if !isPlaceholderNameCharacter(err.str[index]) {
+			break
+		}
+
+		name = err.str[:index+1]
+	}
+
+	return fmt.Sprintf("invalid placeholder name %q, available names: %s", name, strings.Join(err.opts.Names(), ","))
+}
+
+// InvalidPlaceholderOptionError is an invalid `placeholder` option error.
+type InvalidPlaceholderOptionError struct {
+	ph  Placeholder
+	err error
+}
+
+// NewInvalidPlaceholderOptionError returns a new `InvalidPlaceholderOptionError` instance.
+func NewInvalidPlaceholderOptionError(ph Placeholder, err error) *InvalidPlaceholderOptionError {
+	return &InvalidPlaceholderOptionError{
+		ph:  ph,
+		err: err,
+	}
+}
+
+func (err InvalidPlaceholderOptionError) Error() string {
+	return fmt.Sprintf("placeholder %q, %v", err.ph.Name(), err.err)
+}
+
+func (err InvalidPlaceholderOptionError) Unwrap() error {
+	return err.err
+}
diff --git a/pkg/log/format/placeholders/field.go b/pkg/log/format/placeholders/field.go
index 8008b15b49..f43c1dff93 100644
--- a/pkg/log/format/placeholders/field.go
+++ b/pkg/log/format/placeholders/field.go
@@ -9,6 +9,7 @@ const (
 	DownloadDirKeyName = "download-dir"
 	TFPathKeyName      = "tf-path"
 	TFCmdArgsKeyName   = "tf-command-args"
+	TFCmdKeyName       = "tf-command"
 )
 
 type fieldPlaceholder struct {
diff --git a/pkg/log/format/placeholders/placeholder.go b/pkg/log/format/placeholders/placeholder.go
index 93fc6c3d2f..6102add8cf 100644
--- a/pkg/log/format/placeholders/placeholder.go
+++ b/pkg/log/format/placeholders/placeholder.go
@@ -8,14 +8,17 @@ import (
 	"github.com/gruntwork-io/terragrunt/pkg/log/format/options"
 )
 
-const placeholderSign = '%'
+const (
+	placeholderSign             = "%"
+	splitIntoTextAndPlaceholder = 2
+)
 
 // Placeholder is part of the log message, used to format different log values.
 type Placeholder interface {
 	// Name returns a placeholder name.
 	Name() string
-	// GetOption returns the option with the given option name.
-	GetOption(name string) (options.Option, error)
+	// Options returns the placeholder options.
+	Options() options.Options
 	// Format returns the formatted value.
 	Format(data *options.Data) (string, error)
 }
@@ -23,6 +26,20 @@ type Placeholder interface {
 // Placeholders are a set of Placeholders.
 type Placeholders []Placeholder
 
+// NewPlaceholderRegister returns a new `Placeholder` collection instance available for use in a custom format string.
+func NewPlaceholderRegister() Placeholders {
+	return Placeholders{
+		Interval(),
+		Time(),
+		Level(),
+		Message(),
+		Field(WorkDirKeyName, options.PathFormat(options.NonePath, options.RelativePath, options.ShortRelativePath, options.ShortPath)),
+		Field(TFPathKeyName, options.PathFormat(options.NonePath, options.FilenamePath, options.DirectoryPath)),
+		Field(TFCmdArgsKeyName),
+		Field(TFCmdKeyName),
+	}
+}
+
 // Get returns the placeholder by its name.
 func (phs Placeholders) Get(name string) Placeholder {
 	for _, ph := range phs {
@@ -61,161 +78,110 @@ func (phs Placeholders) Format(data *options.Data) (string, error) {
 	return str, nil
 }
 
-// Parse parses the given string and returns a set of placeholders that are then used to format log data.
-func Parse(str string) (Placeholders, error) {
+// findPlaceholder parses the given `str` to find a placeholder name present in the `phs` collection,
+// returns that placeholder, and the rest of the given `str`.
+//
+// e.g. "level(color=green, case=upper) some-text" returns the instance of the `level` placeholder
+// and "(color=green, case=upper) some-text" string.
+func (phs Placeholders) findPlaceholder(str string) (Placeholder, string) { //nolint:ireturn
 	var (
-		registered   = newPlaceholders()
-		placeholders Placeholders
-		next         int
+		placeholder Placeholder
+		optIndex    int
 	)
 
-	for index := 0; index < len(str); index++ {
-		char := str[index]
-
-		if char == placeholderSign {
-			if index+1 >= len(str) {
-				return nil, errors.Errorf("empty placeholder name")
-			}
-
-			if str[index+1] == placeholderSign {
-				str = str[:index] + str[index+1:]
-
-				continue
-			}
-
-			if next != index {
-				placeholder := PlainText(str[next:index])
-				placeholders = append(placeholders, placeholder)
-			}
+	// We don't stop at the first one we find, we look for the longest name.
+	// Of these two `%tf-command` `%tf-command-args` we need to find the second one.
+	for index := range len(str) {
+		if !isPlaceholderNameCharacter(str[index]) {
+			break
+		}
 
-			placeholder, num, err := parsePlaceholder(str[index+1:], registered)
-			if err != nil {
-				return nil, err
-			}
+		name := str[:index+1]
 
-			placeholders = append(placeholders, placeholder)
-			index += num + 1
-			next = index + 1
+		if pl := phs.Get(name); pl != nil {
+			placeholder = pl
+			optIndex = index + 1
 		}
 	}
 
-	if next != len(str) {
-		placeholder := PlainText(str[next:])
-		placeholders = append(placeholders, placeholder)
+	if placeholder != nil {
+		return placeholder, str[optIndex:]
 	}
 
-	return placeholders, nil
+	return findPlaintextPlaceholder(str)
 }
 
-func newPlaceholders() Placeholders {
-	return Placeholders{
-		Interval(),
-		Time(),
-		Level(),
-		Message(),
-		Field(WorkDirKeyName, options.PathFormat(options.NonePath, options.RelativePath, options.ShortRelativePath, options.ShortPath)),
-		Field(TFPathKeyName, options.PathFormat(options.NonePath, options.FilenamePath, options.DirectoryPath)),
-		Field(TFCmdArgsKeyName),
-	}
-}
-
-func parsePlaceholder(str string, registered Placeholders) (Placeholder, int, error) {
+// Parse parses the given `str` and returns a set of placeholders that are then used to format log data.
+func Parse(str string) (Placeholders, error) {
 	var (
-		next        int
-		quoted      byte
-		placeholder Placeholder
-		option      options.Option
+		placeholders Placeholders
+		placeholder  Placeholder
+		err          error
 	)
 
-	for index := range len(str) {
-		char := str[index]
-
-		if char == '"' || char == '\'' {
-			if quoted == 0 {
-				quoted = char
-			} else if quoted == char && index > 0 && str[index-1] != '\\' {
-				quoted = 0
-			}
-		}
+	for {
+		// We need to create a new placeholders collection to avoid overriding options
+		// if the custom format string contains two or more same placeholders.
+		// e.g. "%level(format=full) some-text %level(format=tiny)"
+		placeholderRegister := NewPlaceholderRegister()
 
-		if quoted != 0 {
-			continue
-		}
+		parts := strings.SplitN(str, placeholderSign, splitIntoTextAndPlaceholder)
 
-		if index == 0 && char == '(' {
-			placeholder = PlainText("")
-			next = index + 1
+		if plaintext := parts[0]; plaintext != "" {
+			placeholders = append(placeholders, PlainText(plaintext))
 		}
 
-		if placeholder == nil {
-			if !isPlaceholderNameCharacter(char) {
-				str = str[next:index]
-
-				switch str[0] {
-				case 't':
-					return PlainText("\t"), next, nil
-				case 'n':
-					return PlainText("\n"), next, nil
-				}
-
-				break
-			}
-
-			name := str[next : index+1]
-
-			if placeholder = registered.Get(name); placeholder != nil {
-				next = index + 2 //nolint:mnd
-			}
-
-			continue
+		if len(parts) == 1 {
+			return placeholders, nil
 		}
 
-		if next-1 == index && char != '(' {
-			return placeholder, index - 1, nil
-		}
+		str = parts[1]
 
-		if char == '=' || char == ',' || char == ')' {
-			val := str[next:index]
-			val = strings.TrimSpace(val)
-			val = strings.Trim(val, "'")
-			val = strings.Trim(val, "\"")
-
-			if str[next-1] == '=' {
-				if option == nil {
-					return nil, 0, errors.Errorf("empty option name for placeholder %q", placeholder.Name())
-				}
-
-				if err := option.ParseValue(val); err != nil {
-					return nil, 0, errors.Errorf("invalid value %q for option %q, placeholder %q: %w", val, option.Name(), placeholder.Name(), err)
-				}
-			} else if val != "" {
-				opt, err := placeholder.GetOption(val)
-				if err != nil {
-					return nil, 0, errors.Errorf("invalid option name %q for placeholder %q: %w", val, placeholder.Name(), err)
-				}
-
-				option = opt
-			}
-
-			next = index + 1
+		placeholder, str = placeholderRegister.findPlaceholder(str)
+		if placeholder == nil {
+			return nil, errors.New(NewInvalidPlaceholderNameError(str, placeholderRegister))
 		}
 
-		if char == ')' {
-			return placeholder, index, nil
+		str, err = placeholder.Options().Configure(str)
+		if err != nil {
+			return nil, errors.New(NewInvalidPlaceholderOptionError(placeholder, err))
 		}
+
+		placeholders = append(placeholders, placeholder)
 	}
+}
 
-	if placeholder == nil {
-		return nil, 0, errors.Errorf("invalid placeholder name %q, available values: %s", str, strings.Join(registered.Names(), ","))
+func findPlaintextPlaceholder(str string) (Placeholder, string) { //nolint:ireturn
+	if len(str) == 0 {
+		return nil, str
 	}
 
-	if next < len(str) {
-		return nil, 0, errors.Errorf("invalid option %q for placeholder %q", str[next:], placeholder.Name())
+	switch str[0:1] {
+	case options.OptStartSign:
+		// Unnamed placeholder, format `%(content='...')`.
+		return PlainText(""), str
+	case " ":
+		// Single `%` character, format `% `.
+		return PlainText(placeholderSign), str
+	case placeholderSign:
+		// Escaped `%`, format `%%`.
+		return PlainText(placeholderSign), str[1:]
+	case "t":
+		// Indent, format `%t`.
+		return PlainText("\t"), str[1:]
+	case "n":
+		// Newline, format `%n`.
+		return PlainText("\n"), str[1:]
 	}
 
-	return placeholder, len(str) - 1, nil
+	return nil, str
 }
 
+// isPlaceholderNameCharacter returns true if the given character `c` does not contain any restricted characters for placeholder names.
+//
+// e.g. "time" return `true`.
+// e.g. "time " return `false`.
+// e.g. "time(" return `false`.
 func isPlaceholderNameCharacter(c byte) bool {
 	// Check if the byte value falls within the range of alphanumeric characters
 	return c == '-' || c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
diff --git a/shell/run_shell_cmd.go b/shell/run_shell_cmd.go
index 6cf315cc1a..7aa065801f 100644
--- a/shell/run_shell_cmd.go
+++ b/shell/run_shell_cmd.go
@@ -136,7 +136,8 @@ func RunShellCommandWithOutput(
 		if command == opts.TerraformPath && !opts.ForwardTFStdout {
 			logger := opts.Logger.
 				WithField(placeholders.TFPathKeyName, filepath.Base(opts.TerraformPath)).
-				WithField(placeholders.TFCmdArgsKeyName, args)
+				WithField(placeholders.TFCmdArgsKeyName, args).
+				WithField(placeholders.TFCmdKeyName, cli.Args(args).CommandName())
 
 			if opts.JSONLogFormat && !cli.Args(args).Normalize(cli.SingleDashFlag).Contains(terraform.FlagNameJSON) {
 				outWriter = buildOutWriter(


Test patch:
diff --git a/test/integration_test.go b/test/integration_test.go
index 3c3fd7edc0..d7debb672e 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -22,6 +22,7 @@ import (
 	"github.com/gruntwork-io/terragrunt/internal/errors"
 	"github.com/gruntwork-io/terragrunt/internal/view/diagnostic"
 	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/test/helpers"
 	"github.com/gruntwork-io/terragrunt/util"
@@ -202,40 +203,95 @@ func TestLogCustomFormatOutput(t *testing.T) {
 		logCustomFormat    string
 		expectedStdOutRegs []*regexp.Regexp
 		expectedStdErrRegs []*regexp.Regexp
+		expectedErr        error
 	}{
 		{
-			logCustomFormat:    "%time %level %prefix %msg",
-			expectedStdOutRegs: []*regexp.Regexp{},
-			expectedStdErrRegs: []*regexp.Regexp{
+			"%time %level %prefix %msg",
+			[]*regexp.Regexp{},
+			[]*regexp.Regexp{
 				regexp.MustCompile(`\d{2}:\d{2}:\d{2}\.\d{3} debug ` + absPathReg + regexp.QuoteMeta(" Terragrunt Version:")),
 				regexp.MustCompile(`\d{2}:\d{2}:\d{2}\.\d{3} debug ` + absPathReg + `/dep Module ` + absPathReg + `/dep must wait for 0 dependencies to finish`),
 				regexp.MustCompile(`\d{2}:\d{2}:\d{2}\.\d{3} debug ` + absPathReg + `/app Module ` + absPathReg + `/app must wait for 1 dependencies to finish`),
 			},
+			nil,
 		},
 		{
-			logCustomFormat:    "%interval %level(case=upper) %prefix(path=short-relative,prefix='[',suffix='] ')%msg(path=relative)",
-			expectedStdOutRegs: []*regexp.Regexp{},
-			expectedStdErrRegs: []*regexp.Regexp{
+			"%interval %level(case=upper) %prefix(path=short-relative,prefix='[',suffix='] ')%msg(path=relative)",
+			[]*regexp.Regexp{},
+			[]*regexp.Regexp{
 				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" DEBUG Terragrunt Version:")),
 				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" DEBUG [dep] Module ./dep must wait for 0 dependencies to finish")),
 				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" DEBUG [app] Module ./app must wait for 1 dependencies to finish")),
 			},
+			nil,
 		},
 		{
-			logCustomFormat: "%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command-args(suffix=': ')%msg(path=relative)",
-			expectedStdOutRegs: []*regexp.Regexp{
+			"%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command-args(suffix=': ')%msg(path=relative)",
+			[]*regexp.Regexp{
 				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init -input=false -no-color: Initializing the backend...")),
 				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init -input=false -no-color: Initializing the backend...")),
 			},
-			expectedStdErrRegs: []*regexp.Regexp{
+			[]*regexp.Regexp{
 				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG  Terragrunt Version:")),
 			},
+			nil,
+		},
+		{
+			"%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command(suffix=': ')%msg(path=relative)",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init: Initializing the backend...")),
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init: Initializing the backend...")),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG  Terragrunt Version:")),
+			},
+			nil,
+		},
+		{
+			"%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command()-args %msg(path=relative)",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init-args Initializing the backend...")),
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init-args Initializing the backend...")),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG  -args Terragrunt Version:")),
+			},
+			nil,
+		},
+		{
+			"%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command()-args % aaa %msg(path=relative) %%bbb % ccc",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init-args % aaa Initializing the backend... %bbb % ccc")),
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init-args % aaa Initializing the backend... %bbb % ccc")),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG  -args % aaa Terragrunt Version:")),
+			},
+			nil,
+		},
+		{
+			"%time(color=green) %level %wrong",
+			nil, nil,
+			errors.Errorf(`flag --terragrunt-log-custom-format, invalid placeholder name "wrong", available names: %s`, strings.Join(placeholders.NewPlaceholderRegister().Names(), ",")),
+		},
+		{
+			"%time(colorr=green) %level",
+			nil, nil,
+			errors.Errorf(`flag --terragrunt-log-custom-format, placeholder "time", invalid option name "colorr", available names: %s`, strings.Join(placeholders.Time().Options().Names(), ",")),
+		},
+		{
+			"%time(color=green) %level(format=tinyy)",
+			nil, nil,
+			errors.New(`flag --terragrunt-log-custom-format, placeholder "level", option "format", invalid value "tinyy", available values: full,short,tiny`),
+		},
+		{
+			"%time(=green) %level(format=tiny)",
+			nil, nil,
+			errors.New(`flag --terragrunt-log-custom-format, placeholder "time", empty option name "=green) %level(format=tiny)\""`),
 		},
 	}
 
 	for i, testCase := range testCases {
-		testCase := testCase
-
 		t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) {
 			t.Parallel()
 
@@ -247,6 +303,13 @@ func TestLogCustomFormatOutput(t *testing.T) {
 			require.NoError(t, err)
 
 			stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt run-all init --terragrunt-log-level trace --terragrunt-non-interactive -no-color --terragrunt-no-color --terragrunt-log-custom-format=%q --terragrunt-working-dir %s", testCase.logCustomFormat, rootPath))
+
+			if testCase.expectedErr != nil {
+				assert.EqualError(t, err, testCase.expectedErr.Error())
+
+				return
+			}
+
 			require.NoError(t, err)
 
 			for _, reg := range testCase.expectedStdOutRegs {