Repository: terraform-docs/terraform-docs (go)

Issue:
Support recursive flag in configuration file
### What problem are you facing?

I would like to be able to have my auto-generated documention produced recursively for all submodules, which I can achieve using the --recursive flag, however I cannot set that flag in my .terraform-docs.yml file to ensure all those updating the documentation with terraform-docs consistently update recursively.


### How could terraform-docs help solve your problem?

Support the following in .terraform-docs.yml

```yaml
settings:
   recursive: true
```


Pull request description:
Add recursive to config and enable partial config override
<!--
Thank you for helping to improve terraform-docs!

Please read through https://git.io/JtEzg if this is your first time opening a
terraform-docs pull request. Find us in https://slack.terraform-docs.io/messages/dev if
you need any help contributing.
-->

### Description of your changes

<!--
Briefly describe what this pull request does. Be sure to direct your reviewers'
attention to anything that needs special consideration.

We love pull requests that resolve an open terraform-docs issue. If yours does, you
can uncomment the below line to indicate which issue your PR fixes, for example
"Fixes #500":
-->

Up to now there was only one way to enable recursive execution and that
was with `--recursive` CLI flag. This enables the same behavior but
within config file (i.e. `.terraform-docs.yml`)

Example:

```yaml
recursive:
  enabled: false
  path: modules
```

In the original implementation, when a submodule provided its
configuration it would completely override any config was provided by
its parent module. However with this approach the child module's
configuration will merge into its parent config and override any item
defined by both.

Example:

```
$ cat .terraform-docs.yml
formatter: markdown table
recursive:
  enabled: true
sections:
  show:
    - header
    - inputs
    - outputs
output:
  file: README.md
  mode: inject
settings:
  default: true
  required: true
  type: true

$ cat modules/module-b/.terraform-docs.yml
formatter: yaml
sections:
  show:
    - providers
settings:
  default: false
  escape: false
```

The computed values for module-b configuration will be:

```
formatter: yaml
recursive:
  enabled: true
sections:
  show:
    - providers
output:
  file: README.md
  mode: inject
settings:
  default: false
  escape: false
  required: true
  type: true
```

Fixes #557

I have:

- [x] Read and followed terraform-docs' [contribution process].
- [x] All tests pass when I run `make test`.

### How has this code been tested

<!--
Before reviewers can be confident in the correctness of this pull request, it
needs to tested and shown to be correct. Briefly describe the testing that has
already been done or which is planned for this change.
-->

Create the following tree structure and run terraform-docs recursively against it.

```bash
$ tree -a test/
test/
├── main.tf
├── modules
│   ├── module-1
│   │   ├── main.tf
│   │   ├── README.md
│   │   └── variables.tf
│   ├── module-2
│   │   ├── main.tf
│   │   ├── README.md
│   │   ├── .terraform-docs.yml
│   │   └── variables.tf
│   └── module-3
│       ├── main.tf
│       ├── README.md
│       └── variables.tf
├── outputs.tf
├── README.md
├── .terraform-docs.yml
└── variables.tf
```

Note that `modules/module-2/` partially overrides the config.

```text
$ cat .terraform-docs.yml
formatter: markdown table
recursive:
  enabled: true
sections:
  show:
    - header
    - inputs
    - outputs
output:
  file: README.md
  mode: inject
settings:
  default: true
  html: false
  anchor: false
  required: true
  type: true

$ cat modules/module-2/.terraform-docs.yml
sections:
  show:
    - inputs
sort:
  enabled: false
settings:
  default: false
  anchor: true
```

[contribution process]: https://git.io/JtEzg


Patch:
diff --git a/README.md b/README.md
index 1ead785..82026e1 100644
--- a/README.md
+++ b/README.md
@@ -181,6 +181,10 @@ version: ""
 header-from: main.tf
 footer-from: ""
 
+recursive:
+  enabled: false
+  path: modules
+
 sections:
   hide: []
   show: []
diff --git a/cmd/root.go b/cmd/root.go
index abbbb83..f1bfee6 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -60,8 +60,8 @@ func NewCommand() *cobra.Command {
 
 	// flags
 	cmd.PersistentFlags().StringVarP(&config.File, "config", "c", ".terraform-docs.yml", "config file name")
-	cmd.PersistentFlags().BoolVar(&config.Recursive, "recursive", false, "update submodules recursively (default false)")
-	cmd.PersistentFlags().StringVar(&config.RecursivePath, "recursive-path", "modules", "submodules path to recursively update")
+	cmd.PersistentFlags().BoolVar(&config.Recursive.Enabled, "recursive", false, "update submodules recursively (default false)")
+	cmd.PersistentFlags().StringVar(&config.Recursive.Path, "recursive-path", "modules", "submodules path to recursively update")
 
 	cmd.PersistentFlags().StringSliceVar(&config.Sections.Show, "show", []string{}, "show section ["+print.AllSections+"]")
 	cmd.PersistentFlags().StringSliceVar(&config.Sections.Hide, "hide", []string{}, "hide section ["+print.AllSections+"]")
diff --git a/docs/how-to/recursive-submodules.md b/docs/how-to/recursive-submodules.md
index 834e8e4..29b9c22 100644
--- a/docs/how-to/recursive-submodules.md
+++ b/docs/how-to/recursive-submodules.md
@@ -11,7 +11,7 @@ toc: false
 Since `v0.15.0`
 
 Considering the file strucutre below of main module and its submodules, it is
-possible to generate documentation for them main and all its submodules in one
+possible to generate documentation for the main and all its submodules in one
 execution, with `--recursive` flag.
 
 {{< alert type="warning" >}}
@@ -42,4 +42,41 @@ $ tree .
 ├── outputs.tf
 ├── variables.tf
 └── versions.tf
+
+$ terraform-docs markdown --recursive --output-file README.md .
+```
+
+Alternatively `recursive.enabled` config also can be used instead of CLI flag.
+
+```bash
+$ pwd
+/path/to/module
+
+$ tree .
+.
+├── README.md
+├── main.tf
+├── modules
+│   └── my-sub-module
+│       ├── README.md
+│       ├── main.tf
+│       ├── variables.tf
+│       └── versions.tf
+├── outputs.tf
+├── variables.tf
+├── versions.tf
+├── ...
+└── .terraform-docs.yml
+
+$ cat .terraform-docs.yml
+formatter: markdown table
+
+recursive:
+  enabled: true
+
+output:
+  file: README.md
+  mode: inject
+
+$ terraform-docs .
 ```
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index e1428cb..9f0b06c 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -76,6 +76,10 @@ version: ""
 header-from: main.tf
 footer-from: ""
 
+recursive:
+  enabled: false
+  path: modules
+
 sections:
   hide: []
   show: []
diff --git a/docs/user-guide/configuration/recursive.md b/docs/user-guide/configuration/recursive.md
new file mode 100644
index 0000000..1ccf4c8
--- /dev/null
+++ b/docs/user-guide/configuration/recursive.md
@@ -0,0 +1,52 @@
+---
+title: "recursive"
+description: "recursive configuration"
+menu:
+  docs:
+    parent: "configuration"
+weight: 127
+toc: true
+---
+
+Since `v0.16.0`
+
+Documentation for main module and its submodules can be generated all in one
+execution using `recursive` config. It can be enabled with `recursive.enabled: true`.
+
+Path to find submodules can be configured with `recursive.path` (defaults to
+`modules`).
+
+{{< alert type="warning" >}}
+Generating documentation recursively is allowed only with `output.file`
+set.
+{{< /alert >}}
+
+Each submodule can also have their own `.terraform-docs.yml` config file, to
+override configuration from root module.
+
+## Options
+
+Available options with their default values.
+
+```yaml
+recursive:
+  enabled: false
+  path: modules
+```
+
+## Examples
+
+Enable recursive mode for submodules folder.
+
+```yaml
+recursive:
+  enabled: true
+```
+
+Provide alternative name of submodules folder.
+
+```yaml
+recursive:
+  enabled: true
+  path: submodules-folder
+```
diff --git a/docs/user-guide/configuration/sections.md b/docs/user-guide/configuration/sections.md
index 66d6078..d34d654 100644
--- a/docs/user-guide/configuration/sections.md
+++ b/docs/user-guide/configuration/sections.md
@@ -4,7 +4,7 @@ description: "sections configuration"
 menu:
   docs:
     parent: "configuration"
-weight: 127
+weight: 128
 toc: true
 ---
 
diff --git a/docs/user-guide/configuration/settings.md b/docs/user-guide/configuration/settings.md
index 7eb4bcb..8cb2cf3 100644
--- a/docs/user-guide/configuration/settings.md
+++ b/docs/user-guide/configuration/settings.md
@@ -4,7 +4,7 @@ description: "settings configuration"
 menu:
   docs:
     parent: "configuration"
-weight: 128
+weight: 129
 toc: true
 ---
 
diff --git a/docs/user-guide/configuration/sort.md b/docs/user-guide/configuration/sort.md
index bc27ffe..a147e4d 100644
--- a/docs/user-guide/configuration/sort.md
+++ b/docs/user-guide/configuration/sort.md
@@ -4,7 +4,7 @@ description: "sort configuration"
 menu:
   docs:
     parent: "configuration"
-weight: 129
+weight: 130
 toc: true
 ---
 
diff --git a/examples/.terraform-docs.yml b/examples/.terraform-docs.yml
index e856835..3c27dae 100644
--- a/examples/.terraform-docs.yml
+++ b/examples/.terraform-docs.yml
@@ -1,13 +1,21 @@
-# # see: https://terraform-docs.io/user-guide/configuration/#version
+# # see: https://terraform-docs.io/user-guide/configuration/version
 # version: ">= 0.10, < 0.12"
 
-# see: https://terraform-docs.io/user-guide/configuration/#formatters
+# see: https://terraform-docs.io/user-guide/configuration/formatter
 formatter: markdown table
 
+# see: https://terraform-docs.io/user-guide/configuration/header-from
 header-from: doc.txt
+
+# see: https://terraform-docs.io/user-guide/configuration/footer-from
 footer-from: footer.md
 
-# see: https://terraform-docs.io/user-guide/configuration/#sections
+# see: https://terraform-docs.io/user-guide/configuration/recursive
+# recursive:
+#   enabled: false
+#   path: modules
+
+# see: https://terraform-docs.io/user-guide/configuration/sections
 sections:
   show:
     - header
@@ -16,7 +24,7 @@ sections:
     - modules
     - footer
 
-# # see: https://terraform-docs.io/user-guide/configuration/#content
+# # see: https://terraform-docs.io/user-guide/configuration/content
 # content: |-
 #   Any arbitrary text can be placed anywhere in the content
 #
@@ -38,7 +46,7 @@ sections:
 #
 #   {{ .Inputs }}
 
-# # see: https://terraform-docs.io/user-guide/configuration/#output
+# # see: https://terraform-docs.io/user-guide/configuration/output
 # output:
 #   file: README.md
 #   mode: inject
@@ -53,11 +61,17 @@ sections:
 #     You can also show something after it!
 #     <!-- END_TF_DOCS -->
 
-# see: https://terraform-docs.io/user-guide/configuration/#sort
+# see: https://terraform-docs.io/user-guide/configuration/sort
 sort:
   enabled: true
   by: required
 
+# # https://terraform-docs.io/user-guide/configuration/output-values/
+# output-values:
+#   enabled: false
+#   from: ""
+
+# see: https://terraform-docs.io/user-guide/configuration/settings
 settings:
   indent: 4
   escape: false
diff --git a/internal/cli/run.go b/internal/cli/run.go
index bcb8ab2..f0cea1f 100644
--- a/internal/cli/run.go
+++ b/internal/cli/run.go
@@ -71,8 +71,15 @@ func (r *Runtime) PreRunEFunc(cmd *cobra.Command, args []string) error {
 		return fmt.Errorf("value of '--config' can't be empty")
 	}
 
-	// attempt to read config file and override them with corresponding flags
-	if err := r.readConfig(r.config, ""); err != nil {
+	// read config file and override them with corresponding flags
+	v := viper.New()
+
+	if err := r.readConfig(v, r.config.File, ""); err != nil {
+		return err
+	}
+
+	// and override them with corresponding flags
+	if err := r.unmarshalConfig(v, r.config); err != nil {
 		return err
 	}
 
@@ -95,7 +102,7 @@ func (r *Runtime) RunEFunc(cmd *cobra.Command, args []string) error {
 	// Generating content recursively is only allowed when `config.Output.File`
 	// is set. Otherwise it would be impossible to distinguish where output of
 	// one module ends and the other begin, if content is outpput to stdout.
-	if r.config.Recursive && r.config.RecursivePath != "" {
+	if r.config.Recursive.Enabled && r.config.Recursive.Path != "" {
 		items, err := r.findSubmodules()
 		if err != nil {
 			return err
@@ -120,7 +127,7 @@ func (r *Runtime) RunEFunc(cmd *cobra.Command, args []string) error {
 			return err
 		}
 
-		if r.config.Recursive && cfg.Output.File == "" {
+		if r.config.Recursive.Enabled && cfg.Output.File == "" {
 			return fmt.Errorf("value of '--output-file' cannot be empty with '--recursive'")
 		}
 
@@ -135,11 +142,9 @@ func (r *Runtime) RunEFunc(cmd *cobra.Command, args []string) error {
 // readConfig attempts to read config file, either default `.terraform-docs.yml`
 // or provided file with `-c, --config` flag. It will then attempt to override
 // them with corresponding flags (if set).
-func (r *Runtime) readConfig(config *print.Config, submoduleDir string) error {
-	v := viper.New()
-
+func (r *Runtime) readConfig(v *viper.Viper, file string, submoduleDir string) error {
 	if r.isFlagChanged("config") {
-		v.SetConfigFile(config.File)
+		v.SetConfigFile(file)
 	} else {
 		v.SetConfigName(".terraform-docs")
 		v.SetConfigType("yml")
@@ -159,7 +164,7 @@ func (r *Runtime) readConfig(config *print.Config, submoduleDir string) error {
 	if err := v.ReadInConfig(); err != nil {
 		var perr *os.PathError
 		if errors.As(err, &perr) {
-			return fmt.Errorf("config file %s not found", config.File)
+			return fmt.Errorf("config file %s not found", file)
 		}
 
 		var cerr viper.ConfigFileNotFoundError
@@ -174,6 +179,10 @@ func (r *Runtime) readConfig(config *print.Config, submoduleDir string) error {
 		}
 	}
 
+	return nil
+}
+
+func (r *Runtime) unmarshalConfig(v *viper.Viper, config *print.Config) error {
 	r.bindFlags(v)
 
 	if err := v.Unmarshal(config); err != nil {
@@ -188,7 +197,6 @@ func (r *Runtime) readConfig(config *print.Config, submoduleDir string) error {
 		config.Formatter = r.formatter
 	}
 
-	// TODO
 	config.Parse()
 
 	return nil
@@ -229,11 +237,27 @@ func (r *Runtime) bindFlags(v *viper.Viper) {
 	})
 }
 
+func (r *Runtime) mergeConfig(v *viper.Viper) (*print.Config, error) {
+	copy := *r.config
+	merged := &copy
+
+	if v.IsSet("sections.show") || v.IsSet("sections.hide") {
+		merged.Sections.Show = []string{}
+		merged.Sections.Hide = []string{}
+	}
+
+	if err := r.unmarshalConfig(v, merged); err != nil {
+		return nil, err
+	}
+
+	return merged, nil
+}
+
 // findSubmodules generates list of submodules in `rootDir/RecursivePath` if
 // `--recursive` flag is set. This keeps track of `.terraform-docs.yml` in any
 // of the submodules (if exists) to override the root configuration.
 func (r *Runtime) findSubmodules() ([]module, error) {
-	dir := filepath.Join(r.rootDir, r.config.RecursivePath)
+	dir := filepath.Join(r.rootDir, r.config.Recursive.Path)
 
 	if _, err := os.Stat(dir); os.IsNotExist(err) {
 		return nil, err
@@ -257,9 +281,13 @@ func (r *Runtime) findSubmodules() ([]module, error) {
 		cfgfile := filepath.Join(path, r.config.File)
 
 		if _, err := os.Stat(cfgfile); !os.IsNotExist(err) {
-			cfg = print.DefaultConfig()
+			v := viper.New()
+
+			if err = r.readConfig(v, cfgfile, path); err != nil {
+				return nil, err
+			}
 
-			if err := r.readConfig(cfg, path); err != nil {
+			if cfg, err = r.mergeConfig(v); err != nil {
 				return nil, err
 			}
 		}
diff --git a/print/config.go b/print/config.go
index fa299bb..d20ed30 100644
--- a/print/config.go
+++ b/print/config.go
@@ -18,19 +18,18 @@ import (
 // Config represents all the available config options that can be accessed and
 // passed through CLI.
 type Config struct {
-	File          string       `mapstructure:"-"`
-	Recursive     bool         `mapstructure:"-"`
-	RecursivePath string       `mapstructure:"-"`
-	Formatter     string       `mapstructure:"formatter"`
-	Version       string       `mapstructure:"version"`
-	HeaderFrom    string       `mapstructure:"header-from"`
-	FooterFrom    string       `mapstructure:"footer-from"`
-	Content       string       `mapstructure:"content"`
-	Sections      sections     `mapstructure:"sections"`
-	Output        output       `mapstructure:"output"`
-	OutputValues  outputvalues `mapstructure:"output-values"`
-	Sort          sort         `mapstructure:"sort"`
-	Settings      settings     `mapstructure:"settings"`
+	File         string       `mapstructure:"-"`
+	Formatter    string       `mapstructure:"formatter"`
+	Version      string       `mapstructure:"version"`
+	HeaderFrom   string       `mapstructure:"header-from"`
+	FooterFrom   string       `mapstructure:"footer-from"`
+	Recursive    recursive    `mapstructure:"recursive"`
+	Content      string       `mapstructure:"content"`
+	Sections     sections     `mapstructure:"sections"`
+	Output       output       `mapstructure:"output"`
+	OutputValues outputvalues `mapstructure:"output-values"`
+	Sort         sort         `mapstructure:"sort"`
+	Settings     settings     `mapstructure:"settings"`
 
 	ModuleRoot string
 }
@@ -39,6 +38,7 @@ type Config struct {
 func NewConfig() *Config {
 	return &Config{
 		HeaderFrom:   "main.tf",
+		Recursive:    recursive{},
 		Sections:     sections{},
 		Output:       output{},
 		OutputValues: outputvalues{},
@@ -50,24 +50,42 @@ func NewConfig() *Config {
 // DefaultConfig returns new instance of Config with default values set.
 func DefaultConfig() *Config {
 	return &Config{
-		File:          "",
-		Recursive:     false,
-		RecursivePath: "modules",
-		Formatter:     "",
-		Version:       "",
-		HeaderFrom:    "main.tf",
-		FooterFrom:    "",
-		Content:       "",
-		Sections:      defaultSections(),
-		Output:        defaultOutput(),
-		OutputValues:  defaultOutputValues(),
-		Sort:          defaultSort(),
-		Settings:      defaultSettings(),
+		File:         "",
+		Formatter:    "",
+		Version:      "",
+		HeaderFrom:   "main.tf",
+		FooterFrom:   "",
+		Recursive:    defaultRecursive(),
+		Content:      "",
+		Sections:     defaultSections(),
+		Output:       defaultOutput(),
+		OutputValues: defaultOutputValues(),
+		Sort:         defaultSort(),
+		Settings:     defaultSettings(),
 
 		ModuleRoot: "",
 	}
 }
 
+type recursive struct {
+	Enabled bool   `mapstructure:"enabled"`
+	Path    string `mapstructure:"path"`
+}
+
+func defaultRecursive() recursive {
+	return recursive{
+		Enabled: false,
+		Path:    "modules",
+	}
+}
+
+func (r *recursive) validate() error {
+	if r.Enabled && r.Path == "" {
+		return fmt.Errorf("value of '--recursive-path' can't be empty")
+	}
+	return nil
+}
+
 const (
 	sectionAll          = "all"
 	sectionDataSources  = "data-sources"
@@ -419,11 +437,6 @@ func (c *Config) Validate() error {
 		return fmt.Errorf("value of 'formatter' can't be empty")
 	}
 
-	// recursive
-	if c.Recursive && c.RecursivePath == "" {
-		return fmt.Errorf("value of '--recursive-path' can't be empty")
-	}
-
 	// header-from
 	if c.HeaderFrom == "" {
 		return fmt.Errorf("value of '--header-from' can't be empty")
@@ -439,6 +452,7 @@ func (c *Config) Validate() error {
 	}
 
 	for _, fn := range [](func() error){
+		c.Recursive.validate,
 		c.Sections.validate,
 		c.Output.validate,
 		c.OutputValues.validate,


Test patch:
diff --git a/print/config_test.go b/print/config_test.go
index 9cbda45..e3f2f42 100644
--- a/print/config_test.go
+++ b/print/config_test.go
@@ -561,8 +561,8 @@ func TestConfigValidate(t *testing.T) {
 		},
 		"RecursivePathEmpty": {
 			config: func(c *Config) {
-				c.Recursive = true
-				c.RecursivePath = ""
+				c.Recursive.Enabled = true
+				c.Recursive.Path = ""
 			},
 			wantErr: true,
 			errMsg:  "value of '--recursive-path' can't be empty",