Repository: terraform-docs/terraform-docs
Pull request #272 (2020-06-04 20:39:36)

Add support for .terraform-docs.yml config file
### Prerequisites

Put an `x` into the box(es) that apply:

- [ ] This pull request fixes a bug.
- [x] This pull request adds a feature.
- [ ] This pull request enhances existing functionality.
- [ ] This pull request introduces breaking change.

For more information, see the [Contributing Guide](https://github.com/segmentio/terraform-docs/tree/master/CONTRIBUTING.md).

### Description

This PR adds support for reading configuration from file. There's a new flag added for this `-c, --config` which accepts name of the config file (default to `.terraform-docs.yml`).

Example `.terraform-docs.yml`:

```yaml
formatter: markdown table
header-from: doc.txt
sections:
  hide:
    - inputs
    - outputs
settings:
  indent: 4
  required: false
```

Example Usage:

```bash
terraform-docs ./example/ # this will read the config above
                          # generate output of Markdown Table
                          # Inputs and Outputs will be hidden
                          # Indention will be 4
                          # Required column won't be shown
```

### Issues Resolved

n/a

### Checklist

Put an `x` into all boxes that apply:

- [ ] I have read the [Contributing Guidelines](https://github.com/segmentio/terraform-docs/tree/master/CONTRIBUTING.md).

#### Tests

- [ ] I have added tests to cover my changes.
- [ ] All tests pass when I run `make test`.

#### Documentation

- [x] My change requires a change to the documentation.
- [x] I have updated the documentation accordingly.

#### Code Style

- [ ] My code follows the code style of this project.



Patch:
diff --git a/cmd/root.go b/cmd/root.go
index 85351e0..874408d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -32,16 +32,21 @@ func Execute() error {
 func NewCommand() *cobra.Command {
 	config := cli.DefaultConfig()
 	cmd := &cobra.Command{
-		Args:          cobra.NoArgs,
-		Use:           "terraform-docs",
+		Args:          cobra.MaximumNArgs(1),
+		Use:           "terraform-docs [PATH]",
 		Short:         "A utility to generate documentation from Terraform modules in various output formats",
 		Long:          "A utility to generate documentation from Terraform modules in various output formats",
 		Version:       version.Full(),
 		SilenceUsage:  true,
 		SilenceErrors: true,
+		Annotations:   cli.Annotations("root"),
+		PreRunE:       cli.PreRunEFunc(config),
+		RunE:          cli.RunEFunc(config),
 	}
 
 	// flags
+	cmd.PersistentFlags().StringVarP(&config.File, "config", "c", ".terraform-docs.yml", "config file name")
+
 	cmd.PersistentFlags().StringSliceVar(&config.Sections.Show, "show", []string{}, "show section [header, inputs, outputs, providers, requirements]")
 	cmd.PersistentFlags().StringSliceVar(&config.Sections.Hide, "hide", []string{}, "hide section [header, inputs, outputs, providers, requirements]")
 	cmd.PersistentFlags().BoolVar(&config.Sections.ShowAll, "show-all", true, "show all sections")
diff --git a/docs/CONFIG_FILE.md b/docs/CONFIG_FILE.md
new file mode 100644
index 0000000..9fd4edb
--- /dev/null
+++ b/docs/CONFIG_FILE.md
@@ -0,0 +1,57 @@
+# Config File Reference
+
+All available options for `.terraform-docs.yml`. Note that not all of them can be used at the same time (e.g. `sections.hide` and `sections.show`)
+
+```yaml
+formatter: <FORMATTER_NAME>
+header-from: main.tf
+
+sections:
+  hide-all: false
+  hide:
+    - header
+    - inputs
+    - outputs
+    - providers
+    - requirements
+  show-all: true
+  show:
+    - header
+    - inputs
+    - outputs
+    - providers
+    - requirements
+
+output-values:
+  enabled: false
+  from: ""
+
+sort:
+  enabled: true
+  by:
+    - required
+    - type
+
+settings:
+  color: true
+  escape: true
+  indent: 2
+  required: true
+  sensitive: true
+```
+
+Available options for `FORMATTER_NAME` are:
+
+- `asciidoc`
+- `asciidoc document`
+- `asciidoc table`
+- `json`
+- `markdonw`
+- `markdonw document`
+- `markdonw table`
+- `pretty`
+- `tfvars hcl`
+- `tfvars json`
+- `toml`
+- `xml`
+- `yaml`
diff --git a/docs/FORMATS_GUIDE.md b/docs/FORMATS_GUIDE.md
index bac9bc5..52e0fb8 100644
--- a/docs/FORMATS_GUIDE.md
+++ b/docs/FORMATS_GUIDE.md
@@ -6,9 +6,14 @@ A utility to generate documentation from Terraform modules in various output for
 
 A utility to generate documentation from Terraform modules in various output formats
 
+```
+terraform-docs [PATH] [flags]
+```
+
 ### Options
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
   -h, --help                        help for terraform-docs
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
@@ -39,4 +44,4 @@ A utility to generate documentation from Terraform modules in various output for
 * [terraform-docs xml](/docs/formats/xml.md)	 - Generate XML of inputs and outputs
 * [terraform-docs yaml](/docs/formats/yaml.md)	 - Generate YAML of inputs and outputs
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index ca13e51..e7f0593 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -8,6 +8,36 @@ Support for Terraform `v0.12.x` has been added in `terraform-docs` version `v0.8
 
 Please refer to [Formats Guide](/docs/FORMATS_GUIDE.md) for guidance on output formats, execution syntax, CLI options, etc.
 
+## Configuration File
+
+`terraform-docs` can read the desired formatter and options from a file, instead of being passed to in CLI. This is a convenient way to share the configuation amongst teammates and also CI pipelines. To do so you can use `-c` or `--config` flag which accepts name of the config file (default to `.terraform-docs.yml`). Example `.terraform-docs.yml`:
+
+```yaml
+formatter: markdown table
+header-from: doc.txt
+sections:
+  hide:
+    - inputs
+    - outputs
+settings:
+  indent: 4
+  required: false
+```
+
+when executed:
+
+```bash
+terraform-docs ./example/ # this will read the config above and:
+                          #
+                          # 1) generate output of 'Markdown Table'
+                          # 2) read module 'Header' from 'doc.txt'
+                          # 3) hide 'Inputs' and 'Outputs'
+                          # 4) set 'Indention' to 4
+                          # 5) hide 'Required' column
+```
+
+Please refer to [Config File Reference](/docs/CONFIG_FILE.md) for all the available confiuartion options.
+
 ## Control Visibility of Sections
 
 Output generated by `terraform-docs` consists of different sections (header, requirements, providers, inputs, outputs) which are visible by default. The visibility of these can be controlled by one or combination of : `--show-all`, `--hide-all`, `--show <name>` and `--hide <name>`. For example:
diff --git a/docs/formats/asciidoc-document.md b/docs/formats/asciidoc-document.md
index a83d5d4..3b3520e 100644
--- a/docs/formats/asciidoc-document.md
+++ b/docs/formats/asciidoc-document.md
@@ -19,6 +19,7 @@ terraform-docs asciidoc document [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -419,4 +420,4 @@ generates the following output:
 
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/asciidoc-table.md b/docs/formats/asciidoc-table.md
index 64f6b3f..a121367 100644
--- a/docs/formats/asciidoc-table.md
+++ b/docs/formats/asciidoc-table.md
@@ -19,6 +19,7 @@ terraform-docs asciidoc table [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -375,4 +376,4 @@ generates the following output:
 
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/asciidoc.md b/docs/formats/asciidoc.md
index b0e5f16..c2bf483 100644
--- a/docs/formats/asciidoc.md
+++ b/docs/formats/asciidoc.md
@@ -22,6 +22,7 @@ terraform-docs asciidoc [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -39,4 +40,4 @@ terraform-docs asciidoc [PATH] [flags]
 * [terraform-docs asciidoc document](/docs/formats/asciidoc-document.md)	 - Generate AsciiDoc document of inputs and outputs
 * [terraform-docs asciidoc table](/docs/formats/asciidoc-table.md)	 - Generate AsciiDoc tables of inputs and outputs
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/json.md b/docs/formats/json.md
index 715f2e1..1786c69 100644
--- a/docs/formats/json.md
+++ b/docs/formats/json.md
@@ -20,6 +20,7 @@ terraform-docs json [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -338,4 +339,4 @@ generates the following output:
     }
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/markdown-document.md b/docs/formats/markdown-document.md
index b92f688..1bd6fa8 100644
--- a/docs/formats/markdown-document.md
+++ b/docs/formats/markdown-document.md
@@ -19,6 +19,7 @@ terraform-docs markdown document [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --escape                      escape special characters (default true)
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
@@ -420,4 +421,4 @@ generates the following output:
 
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/markdown-table.md b/docs/formats/markdown-table.md
index fa918e4..5b0619e 100644
--- a/docs/formats/markdown-table.md
+++ b/docs/formats/markdown-table.md
@@ -19,6 +19,7 @@ terraform-docs markdown table [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --escape                      escape special characters (default true)
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
@@ -146,4 +147,4 @@ generates the following output:
 
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/markdown.md b/docs/formats/markdown.md
index 6ef534d..0e11819 100644
--- a/docs/formats/markdown.md
+++ b/docs/formats/markdown.md
@@ -23,6 +23,7 @@ terraform-docs markdown [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -40,4 +41,4 @@ terraform-docs markdown [PATH] [flags]
 * [terraform-docs markdown document](/docs/formats/markdown-document.md)	 - Generate Markdown document of inputs and outputs
 * [terraform-docs markdown table](/docs/formats/markdown-table.md)	 - Generate Markdown tables of inputs and outputs
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/pretty.md b/docs/formats/pretty.md
index 0df42ff..04178c4 100644
--- a/docs/formats/pretty.md
+++ b/docs/formats/pretty.md
@@ -20,6 +20,7 @@ terraform-docs pretty [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -242,4 +243,4 @@ generates the following output:
 
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/tfvars-hcl.md b/docs/formats/tfvars-hcl.md
index 5aeb91c..b607a4f 100644
--- a/docs/formats/tfvars-hcl.md
+++ b/docs/formats/tfvars-hcl.md
@@ -19,6 +19,7 @@ terraform-docs tfvars hcl [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -98,4 +99,4 @@ generates the following output:
     with-url                = ""
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/tfvars-json.md b/docs/formats/tfvars-json.md
index aded015..169e036 100644
--- a/docs/formats/tfvars-json.md
+++ b/docs/formats/tfvars-json.md
@@ -19,6 +19,7 @@ terraform-docs tfvars json [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -100,4 +101,4 @@ generates the following output:
     }
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/tfvars.md b/docs/formats/tfvars.md
index 01b2856..76e683e 100644
--- a/docs/formats/tfvars.md
+++ b/docs/formats/tfvars.md
@@ -15,6 +15,7 @@ Generate terraform.tfvars of inputs
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -32,4 +33,4 @@ Generate terraform.tfvars of inputs
 * [terraform-docs tfvars hcl](/docs/formats/tfvars-hcl.md)	 - Generate HCL format of terraform.tfvars of inputs
 * [terraform-docs tfvars json](/docs/formats/tfvars-json.md)	 - Generate JSON format of terraform.tfvars of inputs
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/toml.md b/docs/formats/toml.md
index adc776d..7138319 100644
--- a/docs/formats/toml.md
+++ b/docs/formats/toml.md
@@ -19,6 +19,7 @@ terraform-docs toml [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -315,4 +316,4 @@ generates the following output:
 
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/xml.md b/docs/formats/xml.md
index b9b75a7..334240d 100644
--- a/docs/formats/xml.md
+++ b/docs/formats/xml.md
@@ -19,6 +19,7 @@ terraform-docs xml [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -337,4 +338,4 @@ generates the following output:
     </module>
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/docs/formats/yaml.md b/docs/formats/yaml.md
index 346ec18..aacc8cd 100644
--- a/docs/formats/yaml.md
+++ b/docs/formats/yaml.md
@@ -19,6 +19,7 @@ terraform-docs yaml [PATH] [flags]
 ### Options inherited from parent commands
 
 ```
+  -c, --config string               config file name (default ".terraform-docs.yml")
       --header-from string          relative path of a file to read header from (default "main.tf")
       --hide strings                hide section [header, inputs, outputs, providers, requirements]
       --hide-all                    hide all sections (default false)
@@ -289,4 +290,4 @@ generates the following output:
         version: '>= 2.2.0'
 
 
-###### Auto generated by spf13/cobra on 24-May-2020
+###### Auto generated by spf13/cobra on 13-Jul-2020
diff --git a/examples/.terraform-docs.yml b/examples/.terraform-docs.yml
new file mode 100644
index 0000000..53bfa12
--- /dev/null
+++ b/examples/.terraform-docs.yml
@@ -0,0 +1,15 @@
+formatter: markdown table
+header-from: doc.txt
+sections:
+  hide-all: true
+  show:
+    - header
+    - inputs
+    - providers
+sort:
+  enabled: true
+  by:
+    - required
+settings:
+  indent: 4
+  escape: false
diff --git a/internal/cli/annotations.go b/internal/cli/annotations.go
index b0ad073..a347790 100644
--- a/internal/cli/annotations.go
+++ b/internal/cli/annotations.go
@@ -1,16 +1,10 @@
 package cli
 
-import (
-	"strings"
-)
-
 // Annotations returns set of annotations for cobra.Commands,
-// specifically the 'command' namd and command 'kind'
+// specifically the command 'name' and command 'kind'
 func Annotations(cmd string) map[string]string {
-	annotations := make(map[string]string)
-	for _, s := range strings.Split(cmd, " ") {
-		annotations["command"] = s
+	return map[string]string{
+		"command": cmd,
+		"kind":    "formatter",
 	}
-	annotations["kind"] = "formatter"
-	return annotations
 }
diff --git a/internal/cli/config.go b/internal/cli/config.go
index 117b332..729ac72 100644
--- a/internal/cli/config.go
+++ b/internal/cli/config.go
@@ -2,15 +2,11 @@ package cli
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/terraform-docs/terraform-docs/internal/module"
 	"github.com/terraform-docs/terraform-docs/pkg/print"
 )
 
-// list of flagset items which explicitly changed from CLI
-var changedfs = make(map[string]bool)
-
 type _sections struct {
 	NoHeader       bool
 	NoInputs       bool
@@ -19,26 +15,26 @@ type _sections struct {
 	NoRequirements bool
 }
 type sections struct {
-	Show       []string
-	Hide       []string
-	ShowAll    bool
-	HideAll    bool
-	Deprecated *_sections
-
-	header       bool
-	inputs       bool
-	outputs      bool
-	providers    bool
-	requirements bool
+	Show       []string  `yaml:"show"`
+	Hide       []string  `yaml:"hide"`
+	ShowAll    bool      `yaml:"show-all"`
+	HideAll    bool      `yaml:"hide-all"`
+	Deprecated _sections `yaml:"-"`
+
+	header       bool `yaml:"-"`
+	inputs       bool `yaml:"-"`
+	outputs      bool `yaml:"-"`
+	providers    bool `yaml:"-"`
+	requirements bool `yaml:"-"`
 }
 
-func defaultSections() *sections {
-	return &sections{
+func defaultSections() sections {
+	return sections{
 		Show:    []string{},
 		Hide:    []string{},
 		ShowAll: true,
 		HideAll: false,
-		Deprecated: &_sections{
+		Deprecated: _sections{
 			NoHeader:       false,
 			NoInputs:       false,
 			NoOutputs:      false,
@@ -110,12 +106,12 @@ func (s *sections) visibility(section string) bool {
 }
 
 type outputvalues struct {
-	Enabled bool
-	From    string
+	Enabled bool   `yaml:"enabled"`
+	From    string `yaml:"from"`
 }
 
-func defaultOutputValues() *outputvalues {
-	return &outputvalues{
+func defaultOutputValues() outputvalues {
+	return outputvalues{
 		Enabled: false,
 		From:    "",
 	}
@@ -132,26 +128,28 @@ func (o *outputvalues) validate() error {
 }
 
 type sortby struct {
-	Required bool
-	Type     bool
+	Required bool `name:"required"`
+	Type     bool `name:"type"`
 }
 type _sort struct {
 	NoSort bool
 }
 type sort struct {
-	Enabled    bool
-	By         *sortby
-	Deprecated *_sort
+	Enabled    bool     `yaml:"enabled"`
+	ByList     []string `yaml:"by"`
+	By         sortby   `yaml:"-"`
+	Deprecated _sort    `yaml:"-"`
 }
 
-func defaultSort() *sort {
-	return &sort{
+func defaultSort() sort {
+	return sort{
 		Enabled: true,
-		By: &sortby{
+		ByList:  []string{},
+		By: sortby{
 			Required: false,
 			Type:     false,
 		},
-		Deprecated: &_sort{
+		Deprecated: _sort{
 			NoSort: false,
 		},
 	}
@@ -164,6 +162,14 @@ func (s *sort) validate() error {
 			return fmt.Errorf("'--%s' and '--no-%s' can't be used together", item, item)
 		}
 	}
+	types := []string{"required", "type"}
+	for _, item := range s.ByList {
+		switch item {
+		case types[0], types[1]:
+		default:
+			return fmt.Errorf("'%s' is not a valid sort type", item)
+		}
+	}
 	if s.By.Required && s.By.Type {
 		return fmt.Errorf("'--sort-by-required' and '--sort-by-type' can't be used together")
 	}
@@ -177,22 +183,22 @@ type _settings struct {
 	NoSensitive bool
 }
 type settings struct {
-	Color      bool
-	Escape     bool
-	Indent     int
-	Required   bool
-	Sensitive  bool
-	Deprecated *_settings
+	Color      bool      `yaml:"color"`
+	Escape     bool      `yaml:"escape"`
+	Indent     int       `yaml:"indent"`
+	Required   bool      `yaml:"required"`
+	Sensitive  bool      `yaml:"sensitive"`
+	Deprecated _settings `yaml:"-"`
 }
 
-func defaultSettings() *settings {
-	return &settings{
+func defaultSettings() settings {
+	return settings{
 		Color:     true,
 		Escape:    true,
 		Indent:    2,
 		Required:  true,
 		Sensitive: true,
-		Deprecated: &_settings{
+		Deprecated: _settings{
 			NoColor:     false,
 			NoEscape:    false,
 			NoRequired:  false,
@@ -213,17 +219,19 @@ func (s *settings) validate() error {
 
 // Config represents all the available config options that can be accessed and passed through CLI
 type Config struct {
-	Formatter    string
-	HeaderFrom   string
-	Sections     *sections
-	OutputValues *outputvalues
-	Sort         *sort
-	Settings     *settings
+	File         string       `yaml:"-"`
+	Formatter    string       `yaml:"formatter"`
+	HeaderFrom   string       `yaml:"header-from"`
+	Sections     sections     `yaml:"sections"`
+	OutputValues outputvalues `yaml:"output-values"`
+	Sort         sort         `yaml:"sort"`
+	Settings     settings     `yaml:"settings"`
 }
 
 // DefaultConfig returns new instance of Config with default values set
 func DefaultConfig() *Config {
 	return &Config{
+		File:         "",
 		Formatter:    "",
 		HeaderFrom:   "main.tf",
 		Sections:     defaultSections(),
@@ -233,10 +241,8 @@ func DefaultConfig() *Config {
 	}
 }
 
-// normalize provided Config
-func (c *Config) normalize(command string) {
-	c.Formatter = strings.Replace(command, "terraform-docs ", "", -1)
-
+// process provided Config
+func (c *Config) process() {
 	// sections
 	if c.Sections.HideAll && !changedfs["show-all"] {
 		c.Sections.ShowAll = false
@@ -251,27 +257,32 @@ func (c *Config) normalize(command string) {
 	c.Sections.requirements = c.Sections.visibility("requirements")
 
 	// sort
-	if !changedfs["sort"] {
+	if !changedfs["sort"] && changedfs["no-sort"] {
 		c.Sort.Enabled = !c.Sort.Deprecated.NoSort
 	}
 
 	// settings
-	if !changedfs["escape"] {
+	if !changedfs["escape"] && changedfs["no-escape"] {
 		c.Settings.Escape = !c.Settings.Deprecated.NoEscape
 	}
-	if !changedfs["color"] {
+	if !changedfs["color"] && changedfs["no-color"] {
 		c.Settings.Color = !c.Settings.Deprecated.NoColor
 	}
-	if !changedfs["required"] {
+	if !changedfs["required"] && changedfs["no-required"] {
 		c.Settings.Required = !c.Settings.Deprecated.NoRequired
 	}
-	if !changedfs["sensitive"] {
+	if !changedfs["sensitive"] && changedfs["no-sensitive"] {
 		c.Settings.Sensitive = !c.Settings.Deprecated.NoSensitive
 	}
 }
 
 // validate config and check for any misuse or misconfiguration
 func (c *Config) validate() error {
+	// formatter
+	if c.Formatter == "" {
+		return fmt.Errorf("value of 'formatter' can't be empty")
+	}
+
 	// header-from
 	if c.HeaderFrom == "" {
 		return fmt.Errorf("value of '--header-from' can't be empty")
@@ -338,12 +349,3 @@ func (c *Config) extract() (*print.Settings, *module.Options) {
 
 	return settings, options
 }
-
-func contains(list []string, name string) bool {
-	for _, i := range list {
-		if i == name {
-			return true
-		}
-	}
-	return false
-}
diff --git a/internal/cli/reader.go b/internal/cli/reader.go
new file mode 100644
index 0000000..89c0373
--- /dev/null
+++ b/internal/cli/reader.go
@@ -0,0 +1,166 @@
+package cli
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"reflect"
+
+	"gopkg.in/yaml.v3"
+)
+
+type cfgreader struct {
+	file      string
+	config    *Config
+	overrides Config
+}
+
+func (c *cfgreader) exist() (bool, error) {
+	if c.file == "" {
+		return false, fmt.Errorf("config file name is missing")
+	}
+	if info, err := os.Stat(c.file); os.IsNotExist(err) || info.IsDir() {
+		return false, err
+	}
+	return true, nil
+}
+
+func (c *cfgreader) parse() error {
+	if ok, err := c.exist(); !ok {
+		return err
+	}
+
+	content, err := ioutil.ReadFile(c.file)
+	if err != nil {
+		return err
+	}
+
+	c.overrides = *c.config
+	if err := yaml.Unmarshal(content, c.config); err != nil {
+		return err
+	}
+
+	if c.config.Sections.HideAll && !changedfs["show-all"] {
+		c.config.Sections.ShowAll = false
+	}
+	if !c.config.Sections.ShowAll && !changedfs["hide-all"] {
+		c.config.Sections.HideAll = true
+	}
+
+	for flag, enabled := range changedfs {
+		if !enabled {
+			continue
+		}
+
+		switch flag {
+		case "header-from":
+			if err := c.overrideValue(flag, c.config, &c.overrides); err != nil {
+				return err
+			}
+		case "show":
+			c.overrideShow()
+		case "hide":
+			c.overrideHide()
+		case "sort":
+			if err := c.overrideValue("enabled", &c.config.Sort, &c.overrides.Sort); err != nil {
+				return err
+			}
+		case "sort-by-required", "sort-by-type":
+			mapping := map[string]string{"sort-by-required": "required", "sort-by-type": "type"}
+			if !contains(c.config.Sort.ByList, mapping[flag]) {
+				c.config.Sort.ByList = append(c.config.Sort.ByList, mapping[flag])
+			}
+			el := reflect.ValueOf(&c.overrides.Sort.By).Elem()
+			field, err := c.findField(el, "name", mapping[flag])
+			if err != nil {
+				return err
+			}
+			if !el.FieldByName(field).Bool() {
+				c.config.Sort.ByList = remove(c.config.Sort.ByList, mapping[flag])
+			}
+		case "output-values", "output-values-from":
+			mapping := map[string]string{"output-values": "enabled", "output-values-from": "from"}
+			if err := c.overrideValue(mapping[flag], &c.config.OutputValues, &c.overrides.OutputValues); err != nil {
+				return err
+			}
+		case "color", "escape", "indent", "required", "sensitive":
+			if err := c.overrideValue(flag, &c.config.Settings, &c.overrides.Settings); err != nil {
+				return err
+			}
+		}
+	}
+
+	if err := c.updateSortTypes(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (c *cfgreader) overrideValue(name string, to interface{}, from interface{}) error {
+	if name == "" || name == "-" {
+		return fmt.Errorf("tag name cannot be blank or empty")
+	}
+	toEl := reflect.ValueOf(to).Elem()
+	field, err := c.findField(toEl, "yaml", name)
+	if err != nil {
+		return err
+	}
+	fromEl := reflect.ValueOf(from).Elem()
+	toEl.FieldByName(field).Set(fromEl.FieldByName(field))
+	return nil
+}
+
+func (c *cfgreader) overrideShow() {
+	for _, item := range c.overrides.Sections.Show {
+		if c.config.Sections.ShowAll {
+			if contains(c.config.Sections.Hide, item) {
+				c.config.Sections.Hide = remove(c.config.Sections.Hide, item)
+				c.config.Sections.Show = remove(c.config.Sections.Show, item)
+			}
+		} else {
+			if !contains(c.config.Sections.Show, item) {
+				c.config.Sections.Show = append(c.config.Sections.Show, item)
+			}
+		}
+	}
+}
+
+func (c *cfgreader) overrideHide() {
+	for _, item := range c.overrides.Sections.Hide {
+		if c.config.Sections.HideAll {
+			if contains(c.config.Sections.Show, item) {
+				c.config.Sections.Show = remove(c.config.Sections.Show, item)
+				c.config.Sections.Hide = remove(c.config.Sections.Hide, item)
+			}
+		} else {
+			if !contains(c.config.Sections.Hide, item) {
+				c.config.Sections.Hide = append(c.config.Sections.Hide, item)
+			}
+		}
+	}
+}
+
+func (c *cfgreader) updateSortTypes() error {
+	for _, item := range c.config.Sort.ByList {
+		el := reflect.ValueOf(&c.config.Sort.By).Elem()
+		field, err := c.findField(el, "name", item)
+		if err != nil {
+			return err
+		}
+		el.FieldByName(field).Set(reflect.ValueOf(true))
+	}
+	return nil
+}
+
+func (c *cfgreader) findField(el reflect.Value, tag string, value string) (string, error) {
+	for i := 0; i < el.NumField(); i++ {
+		f := el.Type().Field(i)
+		t := f.Tag.Get(tag)
+		if t == "" || t == "-" || t != value {
+			continue
+		}
+		return f.Name, nil
+	}
+	return "", fmt.Errorf("field with tag: '%s', value; '%s' not found or not readable", tag, value)
+}
diff --git a/internal/cli/run.go b/internal/cli/run.go
index 112050d..440e457 100644
--- a/internal/cli/run.go
+++ b/internal/cli/run.go
@@ -2,6 +2,8 @@ package cli
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
@@ -10,16 +12,63 @@ import (
 	"github.com/terraform-docs/terraform-docs/internal/module"
 )
 
+// list of flagset items which are explicitly changed from CLI
+var changedfs = make(map[string]bool)
+
 // PreRunEFunc returns actual 'cobra.Command#PreRunE' function
 // for 'formatter' commands. This functions reads and normalizes
 // flags and arguments passed through CLI execution.
 func PreRunEFunc(config *Config) func(*cobra.Command, []string) error {
 	return func(cmd *cobra.Command, args []string) error {
+		formatter := cmd.Annotations["command"]
+
+		// root command must have an argument, otherwise we're going to show help
+		if formatter == "root" && len(args) == 0 {
+			cmd.Help() //nolint:errcheck
+			os.Exit(0)
+		}
+
 		cmd.Flags().VisitAll(func(f *pflag.Flag) {
 			changedfs[f.Name] = f.Changed
 		})
 
-		config.normalize(cmd.CommandPath())
+		// read config file if provided and/or available
+		if config.File == "" {
+			return fmt.Errorf("value of '--config' can't be empty")
+		}
+
+		file := filepath.Join(args[0], config.File)
+		cfgreader := &cfgreader{
+			file:   file,
+			config: config,
+		}
+
+		if found, err := cfgreader.exist(); !found {
+			// config is explicitly provided and file not found, this is an error
+			if changedfs["config"] {
+				return err
+			}
+			// config is not provided and file not found, only show an error for the root command
+			if formatter == "root" {
+				cmd.Help() //nolint:errcheck
+				os.Exit(0)
+			}
+		} else {
+			// config file is found, we're now going to parse it
+			if err := cfgreader.parse(); err != nil {
+				return err
+			}
+		}
+
+		// explicitly setting formatter to Config for non-root commands this
+		// will effectively override formattter properties from config file
+		// if 1) config file exists and 2) formatter is set and 3) explicitly
+		// a subcommand was executed in the terminal
+		if formatter != "root" {
+			config.Formatter = formatter
+		}
+
+		config.process()
 
 		if err := config.validate(); err != nil {
 			return err
diff --git a/internal/cli/util.go b/internal/cli/util.go
new file mode 100644
index 0000000..b0b7528
--- /dev/null
+++ b/internal/cli/util.go
@@ -0,0 +1,29 @@
+package cli
+
+func contains(list []string, name string) bool {
+	for _, v := range list {
+		if v == name {
+			return true
+		}
+	}
+	return false
+}
+
+func index(list []string, name string) int {
+	for i, v := range list {
+		if v == name {
+			return i
+		}
+	}
+	return -1
+}
+
+func remove(list []string, name string) []string {
+	index := index(list, name)
+	if index < 0 {
+		return list
+	}
+	list[index] = list[len(list)-1]
+	list[len(list)-1] = ""
+	return list[:len(list)-1]
+}


Test patch:
diff --git a/internal/cli/annotations_test.go b/internal/cli/annotations_test.go
new file mode 100644
index 0000000..d1d988b
--- /dev/null
+++ b/internal/cli/annotations_test.go
@@ -0,0 +1,35 @@
+package cli
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCommandAnnotations(t *testing.T) {
+	tests := []struct {
+		name    string
+		command string
+	}{
+		{
+			name:    "command annotations",
+			command: "foo",
+		},
+		{
+			name:    "command annotations",
+			command: "foo bar",
+		},
+		{
+			name:    "command annotations",
+			command: "",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			actual := Annotations(tt.command)
+			assert.Equal(tt.command, actual["command"])
+			assert.Equal("formatter", actual["kind"])
+		})
+	}
+}
diff --git a/internal/cli/reader_test.go b/internal/cli/reader_test.go
new file mode 100644
index 0000000..4a88638
--- /dev/null
+++ b/internal/cli/reader_test.go
@@ -0,0 +1,425 @@
+package cli
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExists(t *testing.T) {
+	tests := []struct {
+		name     string
+		config   cfgreader
+		expected bool
+		wantErr  bool
+		errMsg   string
+	}{
+		{
+			name:     "config file exists",
+			config:   cfgreader{file: "testdata/sample-config.yaml"},
+			expected: true,
+			wantErr:  false,
+			errMsg:   "",
+		},
+		{
+			name:     "config file not found",
+			config:   cfgreader{file: "testdata/noop.yaml"},
+			expected: false,
+			wantErr:  true,
+			errMsg:   "",
+		},
+		{
+			name:     "config file empty",
+			config:   cfgreader{file: ""},
+			expected: false,
+			wantErr:  true,
+			errMsg:   "config file name is missing",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			actual, err := tt.config.exist()
+			if tt.wantErr {
+				assert.NotNil(err)
+				if tt.errMsg != "" {
+					assert.Equal(tt.errMsg, err.Error())
+				}
+			} else {
+				assert.Nil(err)
+			}
+			assert.Equal(tt.expected, actual)
+		})
+	}
+}
+
+func TestOverrideValue(t *testing.T) {
+	config := DefaultConfig()
+	override := DefaultConfig()
+	tests := []struct {
+		name       string
+		tag        string
+		to         func() interface{}
+		from       func() interface{}
+		overrideFn func()
+		wantErr    bool
+		errMsg     string
+	}{
+		{
+			name:       "override values of given field",
+			tag:        "header-from",
+			to:         func() interface{} { return config },
+			from:       func() interface{} { return override },
+			overrideFn: func() { override.HeaderFrom = "foo.txt" },
+			wantErr:    false,
+			errMsg:     "",
+		},
+		{
+			name:       "override values of given field",
+			tag:        "enabled",
+			to:         func() interface{} { return &config.Sort },
+			from:       func() interface{} { return &override.Sort },
+			overrideFn: func() { override.Sort.Enabled = false },
+			wantErr:    false,
+			errMsg:     "",
+		},
+		{
+			name:       "override values of given field",
+			tag:        "color",
+			to:         func() interface{} { return &config.Settings },
+			from:       func() interface{} { return &override.Settings },
+			overrideFn: func() { override.Settings.Color = false },
+			wantErr:    false,
+			errMsg:     "",
+		},
+		{
+			name:       "override values of unkwon field tag",
+			tag:        "not-available",
+			to:         func() interface{} { return config },
+			from:       func() interface{} { return override },
+			overrideFn: func() {},
+			wantErr:    true,
+			errMsg:     "field with tag: 'yaml', value; 'not-available' not found or not readable",
+		},
+		{
+			name:       "override values of blank field tag",
+			tag:        "-",
+			to:         func() interface{} { return config },
+			from:       func() interface{} { return override },
+			overrideFn: func() {},
+			wantErr:    true,
+			errMsg:     "tag name cannot be blank or empty",
+		},
+		{
+			name:       "override values of empty field tag",
+			tag:        "",
+			to:         func() interface{} { return config },
+			from:       func() interface{} { return override },
+			overrideFn: func() {},
+			wantErr:    true,
+			errMsg:     "tag name cannot be blank or empty",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			c := cfgreader{config: config}
+
+			tt.overrideFn()
+
+			if !tt.wantErr {
+				// make sure before values are different
+				assert.NotEqual(override, config)
+			}
+
+			// then override property 'from' to 'to'
+			err := c.overrideValue(tt.tag, tt.to(), tt.from())
+
+			if tt.wantErr {
+				assert.NotNil(err)
+				assert.Equal(tt.errMsg, err.Error())
+			} else {
+				assert.Nil(err)
+			}
+
+			// then make sure values are the same now
+			assert.Equal(override, config)
+		})
+	}
+}
+
+func TestOverrideShow(t *testing.T) {
+	tests := []struct {
+		name         string
+		show         []string
+		hide         []string
+		showall      bool
+		overrideShow []string
+		expectedShow []string
+		expectedHide []string
+	}{
+		{
+			name:         "override section show",
+			show:         []string{""},
+			hide:         []string{"inputs", "outputs"},
+			showall:      true,
+			overrideShow: []string{"inputs"},
+			expectedShow: []string{""},
+			expectedHide: []string{"outputs"},
+		},
+		{
+			name:         "override section show",
+			show:         []string{"providers"},
+			hide:         []string{"inputs"},
+			showall:      true,
+			overrideShow: []string{"outputs"},
+			expectedShow: []string{"providers"},
+			expectedHide: []string{"inputs"},
+		},
+		{
+			name:         "override section show",
+			show:         []string{"inputs"},
+			hide:         []string{"providers"},
+			showall:      false,
+			overrideShow: []string{"outputs"},
+			expectedShow: []string{"inputs", "outputs"},
+			expectedHide: []string{"providers"},
+		},
+		{
+			name:         "override section show",
+			show:         []string{"inputs"},
+			hide:         []string{"inputs"},
+			showall:      false,
+			overrideShow: []string{"inputs"},
+			expectedShow: []string{"inputs"},
+			expectedHide: []string{"inputs"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			config := DefaultConfig()
+			override := DefaultConfig()
+			c := cfgreader{config: config, overrides: *override}
+
+			c.config.Sections.Show = tt.show
+			c.config.Sections.Hide = tt.hide
+			c.config.Sections.ShowAll = tt.showall
+			c.overrides.Sections.Show = tt.overrideShow
+
+			c.overrideShow()
+
+			assert.Equal(tt.expectedShow, c.config.Sections.Show)
+			assert.Equal(tt.expectedHide, c.config.Sections.Hide)
+		})
+	}
+}
+
+func TestOverrideHide(t *testing.T) {
+	tests := []struct {
+		name         string
+		show         []string
+		hide         []string
+		hideall      bool
+		overrideHide []string
+		expectedShow []string
+		expectedHide []string
+	}{
+		{
+			name:         "override section hide",
+			show:         []string{"inputs", "outputs"},
+			hide:         []string{""},
+			hideall:      true,
+			overrideHide: []string{"inputs"},
+			expectedShow: []string{"outputs"},
+			expectedHide: []string{""},
+		},
+		{
+			name:         "override section hide",
+			show:         []string{"inputs"},
+			hide:         []string{"providers"},
+			hideall:      true,
+			overrideHide: []string{"outputs"},
+			expectedShow: []string{"inputs"},
+			expectedHide: []string{"providers"},
+		},
+		{
+			name:         "override section hide",
+			show:         []string{"providers"},
+			hide:         []string{"inputs"},
+			hideall:      false,
+			overrideHide: []string{"outputs"},
+			expectedShow: []string{"providers"},
+			expectedHide: []string{"inputs", "outputs"},
+		},
+		{
+			name:         "override section hide",
+			show:         []string{"inputs"},
+			hide:         []string{"inputs"},
+			hideall:      false,
+			overrideHide: []string{"inputs"},
+			expectedShow: []string{"inputs"},
+			expectedHide: []string{"inputs"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			config := DefaultConfig()
+			override := DefaultConfig()
+			c := cfgreader{config: config, overrides: *override}
+
+			c.config.Sections.Show = tt.show
+			c.config.Sections.Hide = tt.hide
+			c.config.Sections.HideAll = tt.hideall
+			c.overrides.Sections.Hide = tt.overrideHide
+
+			c.overrideHide()
+
+			assert.Equal(tt.expectedShow, c.config.Sections.Show)
+			assert.Equal(tt.expectedHide, c.config.Sections.Hide)
+		})
+	}
+}
+
+func TestUpdateSortTypes(t *testing.T) {
+	tests := []struct {
+		name       string
+		appendFn   func(config *Config)
+		expectedFn func(config *Config) bool
+		wantErr    bool
+		errMsg     string
+	}{
+		{
+			name:       "override values of given field",
+			appendFn:   func(config *Config) { config.Sort.ByList = append(config.Sort.ByList, "required") },
+			expectedFn: func(config *Config) bool { return config.Sort.By.Required },
+			wantErr:    false,
+			errMsg:     "",
+		},
+		{
+			name:       "override values of given field",
+			appendFn:   func(config *Config) { config.Sort.ByList = append(config.Sort.ByList, "type") },
+			expectedFn: func(config *Config) bool { return config.Sort.By.Type },
+			wantErr:    false,
+			errMsg:     "",
+		},
+		{
+			name:       "override values of given field",
+			appendFn:   func(config *Config) { config.Sort.ByList = append(config.Sort.ByList, "unknown") },
+			expectedFn: func(config *Config) bool { return false },
+			wantErr:    true,
+			errMsg:     "field with tag: 'name', value; 'unknown' not found or not readable",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			config := DefaultConfig()
+			c := cfgreader{config: config}
+
+			tt.appendFn(config)
+
+			// make sure before values is false
+			assert.Equal(false, tt.expectedFn(config))
+
+			// then update sort types
+			err := c.updateSortTypes()
+
+			if tt.wantErr {
+				assert.NotNil(err)
+				assert.Equal(tt.errMsg, err.Error())
+			} else {
+				// then make sure values is true
+				assert.Nil(err)
+				assert.Equal(true, tt.expectedFn(config))
+			}
+		})
+	}
+}
+
+func TestFindField(t *testing.T) {
+	type sample struct {
+		A string `foo:"a"`
+		B string `bar:"b"`
+		C string `baz:"-"`
+		D string `fizz:"-"`
+		E string `buzz:""`
+		F string
+	}
+	tests := []struct {
+		name     string
+		tag      string
+		value    string
+		expected string
+		wantErr  bool
+		errMsg   string
+	}{
+		{
+			name:     "find field with given tag",
+			tag:      "foo",
+			value:    "a",
+			expected: "A",
+			wantErr:  false,
+			errMsg:   "",
+		},
+		{
+			name:     "find field with given tag",
+			tag:      "bar",
+			value:    "b",
+			expected: "B",
+			wantErr:  false,
+			errMsg:   "",
+		},
+		{
+			name:     "find field with tag none",
+			tag:      "baz",
+			value:    "-",
+			expected: "",
+			wantErr:  true,
+			errMsg:   "field with tag: 'baz', value; '-' not found or not readable",
+		},
+		{
+			name:     "find field with tag none",
+			tag:      "fizz",
+			value:    "-",
+			expected: "",
+			wantErr:  true,
+			errMsg:   "field with tag: 'fizz', value; '-' not found or not readable",
+		},
+		{
+			name:     "find field with tag empty",
+			tag:      "buzz",
+			value:    "",
+			expected: "",
+			wantErr:  true,
+			errMsg:   "field with tag: 'buzz', value; '' not found or not readable",
+		},
+		{
+			name:     "find field with tag unknown",
+			tag:      "unknown",
+			value:    "unknown",
+			expected: "",
+			wantErr:  true,
+			errMsg:   "field with tag: 'unknown', value; 'unknown' not found or not readable",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			c := cfgreader{}
+			el := reflect.ValueOf(&sample{}).Elem()
+
+			actual, err := c.findField(el, tt.tag, tt.value)
+
+			if tt.wantErr {
+				assert.NotNil(err)
+				assert.Equal(tt.errMsg, err.Error())
+			} else {
+				assert.Nil(err)
+				assert.Equal(tt.expected, actual)
+			}
+		})
+	}
+}
diff --git a/internal/cli/testdata/sample-config.yaml b/internal/cli/testdata/sample-config.yaml
new file mode 100644
index 0000000..f5e4aee
--- /dev/null
+++ b/internal/cli/testdata/sample-config.yaml
@@ -0,0 +1,1 @@
+formatter: foo
diff --git a/internal/cli/util_test.go b/internal/cli/util_test.go
new file mode 100644
index 0000000..82198c3
--- /dev/null
+++ b/internal/cli/util_test.go
@@ -0,0 +1,121 @@
+package cli
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSliceContains(t *testing.T) {
+	list := []string{"foo", "bar", "buzz"}
+	tests := []struct {
+		name     string
+		item     string
+		expected bool
+	}{
+		{
+			name:     "item exists in slice",
+			item:     "foo",
+			expected: true,
+		},
+		{
+			name:     "item exists in slice",
+			item:     "bar",
+			expected: true,
+		},
+		{
+			name:     "item not exist in slice",
+			item:     "fizz",
+			expected: false,
+		},
+		{
+			name:     "empty item",
+			item:     "",
+			expected: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			actual := contains(list, tt.item)
+			assert.Equal(tt.expected, actual)
+		})
+	}
+}
+
+func TestSliceIndex(t *testing.T) {
+	list := []string{"foo", "bar", "buzz"}
+	tests := []struct {
+		name     string
+		item     string
+		expected int
+	}{
+		{
+			name:     "index of item exists in slice",
+			item:     "foo",
+			expected: 0,
+		},
+		{
+			name:     "index of item exists in slice",
+			item:     "bar",
+			expected: 1,
+		},
+		{
+			name:     "index of item not exist in slice",
+			item:     "fizz",
+			expected: -1,
+		},
+		{
+			name:     "index of empty item",
+			item:     "",
+			expected: -1,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			actual := index(list, tt.item)
+			assert.Equal(tt.expected, actual)
+		})
+	}
+}
+
+func TestSliceRemove(t *testing.T) {
+	list := []string{"foo", "bar", "buzz"}
+	tests := []struct {
+		name     string
+		item     string
+		expected []string
+	}{
+		{
+			name:     "remove item exists in slice",
+			item:     "foo",
+			expected: []string{"buzz", "bar"},
+		},
+		{
+			name:     "remove item exists in slice",
+			item:     "bar",
+			expected: []string{"foo", "buzz"},
+		},
+		{
+			name:     "remove item not exist in slice",
+			item:     "fizz",
+			expected: []string{"foo", "bar", "buzz"},
+		},
+		{
+			name:     "remove empty item",
+			item:     "",
+			expected: []string{"foo", "bar", "buzz"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			cpy := make([]string, len(list))
+			copy(cpy, list)
+			actual := remove(cpy, tt.item)
+			assert.Equal(len(tt.expected), len(actual))
+			assert.Equal(tt.expected, actual)
+		})
+	}
+}