Files
teleport/lib/utils/usage_docs_test.go
T
Tiago Silva fb98c980ec Add enum value rendering to CLI docs and remove duplicate descriptions (#66466)
This commit enhances the CLI reference documentation by automatically
displaying valid enum values in the "Default" column of flag tables,
eliminating the need to manually list these values in flag descriptions.
It also adds a CI step to verify that CLI reference docs stay in sync
with source code changes.

The core implementation uses reflection to extract enum values from
kingpin flags and formats them as "(valid: `value1`, `value2`, ...)".
All CLI source files have been updated to remove duplicate enum value
lists from flag help text, as these are now automatically rendered.

A new 'cli-docs-up-to-date' Makefile target and corresponding GitHub
Actions workflow job ensure that any changes to CLI flags are reflected
in the generated documentation.

Example output:
Before: |`--format`|`yaml`|Output format, 'yaml', 'json', or 'text'|
After:  |`--format`|`yaml` (valid: `yaml`, `json`, `text`)|Output format.|

Signed-off-by: Tiago Silva <tiago.silva@goteleport.com>
2026-05-08 13:53:15 +00:00

593 lines
13 KiB
Go

// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//go:build docs
package utils
import (
"bytes"
"os"
"strings"
"testing"
"time"
"github.com/alecthomas/kingpin/v2"
"github.com/stretchr/testify/require"
)
func TestUpdateAppUsageTemplate(t *testing.T) {
tests := []struct {
name string
makeApp func() *kingpin.Application
expectSubstring string // The @ character is replaced with a backtick
config generatorConfig
}{
{
name: "subcommand flags and global flags",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("hello", "Hello.")
create := app.Command("create", "Create.")
create.Flag("name", "The name of the resource").Default("myresource").String()
createRocket := create.Command("rocket", "Rocket.")
createRocket.Flag("launch", "Whether to launch the Rocket").Bool()
return app
},
config: generatorConfig{
Introduction: "This is the main CLI tool.",
},
expectSubstring: `---
title: myapp Reference
description: Provides a comprehensive list of commands, arguments, and flags for myapp.
sidebar_label: myapp
tags:
- reference
- platform-wide
---
{/*vale messaging = NO*/}
This guide provides a comprehensive list of commands, arguments, and flags for
myapp.
This is the main CLI tool.
@@@code
$ myapp [<flags>] <command> [<args> ...]
@@@
Global flags:
|Flag|Default|Description|
|---|---|---|
|@--config@|@config.yaml@|The location of the config file|
## myapp create rocket
Rocket.
Usage:
@@@code
$ myapp create rocket [<flags>]
@@@
Flags:
|Flag|Default|Description|
|---|---|---|
|@--[no-]launch@|@false@|Whether to launch the Rocket|
## myapp hello
Hello.
Usage:
@@@code
$ myapp hello
@@@
## myapp help
Show help.
Usage:
@@@code
$ myapp help [<command>...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|command|none (optional)|Show help on command.|
`,
},
{
name: "multiple main command flags",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Flag("verbosity", "Verbosity level.").Default("3").Int()
app.Flag("dry-run", "Whether to use dry-run mode").Default("false").Bool()
return app
},
config: generatorConfig{
Introduction: "This is the main CLI tool.",
},
expectSubstring: `This guide provides a comprehensive list of commands, arguments, and flags for
myapp.
This is the main CLI tool.
@@@code
$ myapp [<flags>] <command> [<args> ...]
@@@
Global flags:
|Flag|Default|Description|
|---|---|---|
|@--config@|@config.yaml@|The location of the config file|
|@--[no-]dry-run@|@false@|Whether to use dry-run mode|
|@--verbosity@|@3@|Verbosity level.|
`,
},
{
name: "multiple subcommand flags",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
create := app.Command("create", "Create a resource.")
create.Flag("verbosity", "Verbosity level.").Default("3").Int()
create.Flag("dry-run", "Whether to use dry-run mode").Default("false").Bool()
return app
},
expectSubstring: `## myapp create
Create a resource.
Usage:
@@@code
$ myapp create [<flags>]
@@@
Flags:
|Flag|Default|Description|
|---|---|---|
|@--[no-]dry-run@|@false@|Whether to use dry-run mode|
|@--verbosity@|@3@|Verbosity level.|
`,
},
{
name: "multiple sub-command args",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
create := app.Command("create", "Create.")
create.Arg("verbosity", "Verbosity level.").Default("3").Int()
create.Arg("dry-run", "Whether to use dry-run mode").Default("false").Bool()
return app
},
expectSubstring: `## myapp create
Create.
Usage:
@@@code
$ myapp create [<verbosity>] [<dry-run>]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|dry-run|@false@ (optional)|Whether to use dry-run mode|
|verbosity|@3@ (optional)|Verbosity level.|
`,
},
{
name: "sub-command order",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("create", "Create a resource.")
app.Command("validate", "Validate the config.")
app.Command("connect", "Connect to a server.")
return app
},
expectSubstring: `## myapp connect
Connect to a server.
Usage:
@@@code
$ myapp connect
@@@
## myapp create
Create a resource.
Usage:
@@@code
$ myapp create
@@@
## myapp help
Show help.
Usage:
@@@code
$ myapp help [<command>...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|command|none (optional)|Show help on command.|
## myapp validate
Validate the config.
Usage:
@@@code
$ myapp validate
@@@
`,
},
{
name: "level-3 command order",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
mfa := app.Command("mfa", "Manage MFA resources.")
mfa.Command("add", "Add an MFA device.")
app.Command("create", "Create a resource")
return app
},
expectSubstring: `## myapp create
Create a resource
Usage:
@@@code
$ myapp create
@@@
## myapp help
Show help.
Usage:
@@@code
$ myapp help [<command>...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|command|none (optional)|Show help on command.|
## myapp mfa add
Add an MFA device.
Usage:
@@@code
$ myapp mfa add
@@@
`,
},
{
name: "empty arg",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("kubectl", "Proxy kubectl commands.")
kubectl := app.Command("kubectl", "Proxy kubectl commands.").Interspersed(false)
// This hack is required in order to accept any args for tsh kubectl.
kubectl.Arg("", "").StringsVar(new([]string))
return app
},
expectSubstring: `## myapp kubectl
Proxy kubectl commands.
Usage:
@@@code
$ myapp kubectl [args...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|args|none (optional)|Arbitrary arguments|
`,
},
{
name: "hidden flag",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("kubectl", "Proxy kubectl commands.")
kubectl := app.Command("kubectl", "Proxy kubectl commands.").Interspersed(false)
kubectl.Flag("diag", "Run diagnostics").Hidden().Bool()
app.Command("log", "Print logs")
return app
},
expectSubstring: `## myapp kubectl
Proxy kubectl commands.
Usage:
@@@code
$ myapp kubectl
@@@
## myapp log
`,
},
{
name: "main command env vars",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("verbosity", "Verbosity level.").Default("3").Envar("MYAPP_VERBOSITY").Int()
return app
},
expectSubstring: `Global flags:
|Flag|Default|Description|
|---|---|---|
|@--verbosity@|@3@|Verbosity level.|
Global environment variables:
|Variable|Default|Description|
|---|---|---|
|@MYAPP_VERBOSITY@|@3@|Verbosity level.|
`,
},
{
name: "subcommand env vars",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
create := app.Command("create", "Create a resource.")
create.Flag("name", "The name of the resource").Envar("CREATE_NAME").Default("myresource").String()
create.Arg("type", "The type of the resource").Envar("CREATE_TYPE").String()
return app
},
expectSubstring: `## myapp create
Create a resource.
Usage:
@@@code
$ myapp create [<flags>] [<type>]
@@@
Environment variables:
|Variable|Default|Description|
|---|---|---|
|@CREATE_NAME@|@myresource@|The name of the resource|
|@CREATE_TYPE@|none (optional)|The type of the resource|
Flags:
|Flag|Default|Description|
|---|---|---|
|@--name@|@myresource@|The name of the resource|
Arguments:
|Argument|Default|Description|
|---|---|---|
|type|none (optional)|The type of the resource|
`,
},
{
name: "overridden dynamic flag value",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("start_time", "When to start the app").Default(time.Now().String()).String()
return app
},
config: generatorConfig{
Introduction: "This is the main CLI tool.",
FlagDefaultOverrides: []flagDefaultOverride{
{
FullCommand: "myapp",
Flag: "start_time",
Value: "now",
},
},
},
expectSubstring: `---
title: myapp Reference
description: Provides a comprehensive list of commands, arguments, and flags for myapp.
sidebar_label: myapp
tags:
- reference
- platform-wide
---
{/*vale messaging = NO*/}
This guide provides a comprehensive list of commands, arguments, and flags for
myapp.
This is the main CLI tool.
@@@code
$ myapp [<flags>] <command> [<args> ...]
@@@
Global flags:
|Flag|Default|Description|
|---|---|---|
|@--start_time@|@now@|When to start the app|
`,
},
{
name: "overridden subcommand argument default",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
create := app.Command("create", "Create.")
create.Arg("verbosity", "Verbosity level.").Default("3").Int()
create.Arg("start_time", "When to start the app").Default(time.Now().String()).String()
return app
},
config: generatorConfig{
Introduction: "This is the main CLI tool.",
ArgDefaultOverrides: []argDefaultOverride{
{
FullCommand: "myapp create",
Arg: "start_time",
Value: "now",
},
},
},
expectSubstring: `## myapp create
Create.
Usage:
@@@code
$ myapp create [<verbosity>] [<start_time>]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|start_time|@now@ (optional)|When to start the app|
|verbosity|@3@ (optional)|Verbosity level.|
`,
},
{
name: "enum flag with default value",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
var format string
app.Flag("format", "Output format").Default("text").EnumVar(&format, "text", "json", "yaml")
return app
},
config: generatorConfig{
Introduction: "This is the main CLI tool.",
},
expectSubstring: `Global flags:
|Flag|Default|Description|
|---|---|---|
|@--format@|@text@ (valid: @text@, @json@, @yaml@)|Output format|
`,
},
{
name: "enum flag in subcommand",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
list := app.Command("list", "List resources.")
var mode string
list.Flag("mode", "List mode").Default("all").EnumVar(&mode, "all", "active", "inactive")
return app
},
config: generatorConfig{
Introduction: "This is the main CLI tool.",
},
expectSubstring: `## myapp list
List resources.
Usage:
@@@code
$ myapp list [<flags>]
@@@
Flags:
|Flag|Default|Description|
|---|---|---|
|@--mode@|@all@ (valid: @all@, @active@, @inactive@)|List mode|
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := tt.makeApp()
var buffer bytes.Buffer
app.UsageWriter(&buffer)
args := []string{"help"}
app.Terminate(func(int) {})
docsUsageTemplatePath := "docs-usage.md.tmpl"
f, err := os.Open(docsUsageTemplatePath)
require.NoError(t, err)
updateAppUsageTemplate(f, tt.config, app)
// kingpin only adds a help command if there is at least
// one subcommand. Make sure that all test cases
// introduce a help command.
app.HelpCommand = app.Command("help", "Print help for the application.")
// HelpCommand is triggered on PreAction during Parse.
// See kingpin.Application.init for more details.
_, err = app.Parse(args)
require.NoError(t, err)
expected := strings.ReplaceAll(tt.expectSubstring, "@", "`")
require.Contains(t, buffer.String(), expected)
})
}
}