Skip to content

Commit 185095a

Browse files
Copilotspboyervhvb1989JeffreyCA
authored
Add azd config options command for config discoverability (#6390)
* Initial plan * Add azd config options command to list all available config settings Co-authored-by: spboyer <[email protected]> * Add tests and snapshot updates for config options command Co-authored-by: spboyer <[email protected]> * Fix log.Panic error formatting as per code review feedback Co-authored-by: spboyer <[email protected]> * Add environment variable information to config options output Co-authored-by: spboyer <[email protected]> * Add Current Value column to config options table output Co-authored-by: spboyer <[email protected]> * Address code review feedback and fix figspec snapshot test - Define EnvOnlyPrefix constant for environment-only config keys - Improve error handling to log non-NotExist errors instead of silently ignoring - Update figspec test snapshots for new config options command - Use constant instead of magic string for environment-only check Co-authored-by: vhvb1989 <[email protected]> * Add help examples for azd config options command - Add getCmdConfigOptionsHelpFooter function with usage examples - Include examples for table format, JSON format, and setting config values - Update usage test snapshots to include new help examples - Improves discoverability and user experience for the new command Co-authored-by: spboyer <[email protected]> * Change default output format to readable list format - Change default format from table to list format similar to list-alpha - Table format is still available with -o table flag - List format is much more readable and doesn't wrap on normal terminals - Remove Current Value column from default output (users can use azd config get) - Update help examples to reflect new default format - Update usage test snapshots Addresses feedback about table readability issues from @JeffreyCA and @SophCarp Co-authored-by: spboyer <[email protected]> * Add integration tests for config options action - Add tests for JSON format output - Add tests for table format output - Add tests for default list format output - Tests verify expected config options are present - Tests verify output contains correct fields Addresses test coverage feedback from @spboyer Co-authored-by: spboyer <[email protected]> * Fix gofmt formatting in config_options_test.go - Remove trailing whitespace and fix indentation - All formatting issues resolved Addresses feedback from @JeffreyCA Co-authored-by: JeffreyCA <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: spboyer <[email protected]> Co-authored-by: vhvb1989 <[email protected]> Co-authored-by: JeffreyCA <[email protected]>
1 parent 9ca0e52 commit 185095a

File tree

9 files changed

+558
-0
lines changed

9 files changed

+558
-0
lines changed

cli/azd/cmd/config.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"maps"
11+
"os"
1112
"path/filepath"
1213
"runtime"
1314
"slices"
@@ -169,6 +170,20 @@ $ azd config set defaults.location eastus`,
169170
ActionResolver: newConfigListAlphaAction,
170171
})
171172

173+
group.Add("options", &actions.ActionDescriptorOptions{
174+
Command: &cobra.Command{
175+
Short: "List all available configuration settings.",
176+
Long: "List all possible configuration settings that can be set with azd, " +
177+
"including descriptions and allowed values.",
178+
},
179+
HelpOptions: actions.ActionHelpOptions{
180+
Footer: getCmdConfigOptionsHelpFooter,
181+
},
182+
ActionResolver: newConfigOptionsAction,
183+
OutputFormats: []output.Format{output.JsonFormat, output.TableFormat, output.NoneFormat},
184+
DefaultFormat: output.NoneFormat,
185+
})
186+
172187
return group
173188
}
174189

@@ -510,3 +525,200 @@ func getCmdListAlphaHelpFooter(*cobra.Command) string {
510525
),
511526
})
512527
}
528+
529+
func getCmdConfigOptionsHelpFooter(*cobra.Command) string {
530+
return generateCmdHelpSamplesBlock(map[string]string{
531+
"List all available configuration settings": output.WithHighLightFormat(
532+
"azd config options",
533+
),
534+
"List all available configuration settings in JSON format": output.WithHighLightFormat(
535+
"azd config options -o json",
536+
),
537+
"List all available configuration settings in table format": output.WithHighLightFormat(
538+
"azd config options -o table",
539+
),
540+
})
541+
}
542+
543+
// azd config options
544+
545+
type configOptionsAction struct {
546+
console input.Console
547+
formatter output.Formatter
548+
writer io.Writer
549+
configManager config.UserConfigManager
550+
args []string
551+
}
552+
553+
func newConfigOptionsAction(
554+
console input.Console,
555+
formatter output.Formatter,
556+
writer io.Writer,
557+
configManager config.UserConfigManager,
558+
args []string) actions.Action {
559+
return &configOptionsAction{
560+
console: console,
561+
formatter: formatter,
562+
writer: writer,
563+
configManager: configManager,
564+
args: args,
565+
}
566+
}
567+
568+
func (a *configOptionsAction) Run(ctx context.Context) (*actions.ActionResult, error) {
569+
options := config.GetAllConfigOptions()
570+
571+
// Load current config to show current values
572+
currentConfig, err := a.configManager.Load()
573+
if err != nil {
574+
// Only ignore "file not found" errors; other errors should be logged
575+
if !os.IsNotExist(err) {
576+
// Log the error but continue with empty config to ensure the command still works
577+
fmt.Fprintf(a.console.Handles().Stderr, "Warning: failed to load config: %v\n", err)
578+
}
579+
currentConfig = config.NewEmptyConfig()
580+
}
581+
582+
if a.formatter.Kind() == output.JsonFormat {
583+
err := a.formatter.Format(options, a.writer, nil)
584+
if err != nil {
585+
return nil, fmt.Errorf("failed formatting config options: %w", err)
586+
}
587+
return nil, nil
588+
}
589+
590+
if a.formatter.Kind() == output.TableFormat {
591+
// Table format
592+
type tableRow struct {
593+
Key string
594+
Description string
595+
Type string
596+
CurrentValue string
597+
AllowedValues string
598+
EnvVar string
599+
Example string
600+
}
601+
602+
var rows []tableRow
603+
for _, option := range options {
604+
allowedValues := ""
605+
if len(option.AllowedValues) > 0 {
606+
allowedValues = strings.Join(option.AllowedValues, ", ")
607+
}
608+
609+
// Get current value from config
610+
currentValue := ""
611+
// Skip environment-only variables (those with keys starting with EnvOnlyPrefix)
612+
if !strings.HasPrefix(option.Key, config.EnvOnlyPrefix) {
613+
if val, ok := currentConfig.Get(option.Key); ok {
614+
// Convert value to string representation
615+
switch v := val.(type) {
616+
case string:
617+
currentValue = v
618+
case map[string]any:
619+
currentValue = "<object>"
620+
case []any:
621+
currentValue = "<array>"
622+
default:
623+
currentValue = fmt.Sprintf("%v", v)
624+
}
625+
}
626+
}
627+
628+
rows = append(rows, tableRow{
629+
Key: option.Key,
630+
Description: option.Description,
631+
Type: option.Type,
632+
CurrentValue: currentValue,
633+
AllowedValues: allowedValues,
634+
EnvVar: option.EnvVar,
635+
Example: option.Example,
636+
})
637+
}
638+
639+
columns := []output.Column{
640+
{
641+
Heading: "Key",
642+
ValueTemplate: "{{.Key}}",
643+
},
644+
{
645+
Heading: "Description",
646+
ValueTemplate: "{{.Description}}",
647+
},
648+
{
649+
Heading: "Type",
650+
ValueTemplate: "{{.Type}}",
651+
},
652+
{
653+
Heading: "Current Value",
654+
ValueTemplate: "{{.CurrentValue}}",
655+
},
656+
{
657+
Heading: "Allowed Values",
658+
ValueTemplate: "{{.AllowedValues}}",
659+
},
660+
{
661+
Heading: "Environment Variable",
662+
ValueTemplate: "{{.EnvVar}}",
663+
},
664+
{
665+
Heading: "Example",
666+
ValueTemplate: "{{.Example}}",
667+
},
668+
}
669+
670+
err = a.formatter.Format(rows, a.writer, output.TableFormatterOptions{
671+
Columns: columns,
672+
})
673+
if err != nil {
674+
return nil, fmt.Errorf("failed formatting config options: %w", err)
675+
}
676+
677+
return nil, nil
678+
}
679+
680+
// Default format: human-readable list (similar to list-alpha)
681+
var optionsOutput []string
682+
for _, option := range options {
683+
var parts []string
684+
parts = append(parts, fmt.Sprintf("Key: %s", option.Key))
685+
parts = append(parts, fmt.Sprintf("Description: %s", option.Description))
686+
687+
// Get current value from config if available
688+
if !strings.HasPrefix(option.Key, config.EnvOnlyPrefix) {
689+
if val, ok := currentConfig.Get(option.Key); ok {
690+
var currentValue string
691+
switch v := val.(type) {
692+
case string:
693+
currentValue = v
694+
case map[string]any:
695+
currentValue = "<object>"
696+
case []any:
697+
currentValue = "<array>"
698+
default:
699+
currentValue = fmt.Sprintf("%v", v)
700+
}
701+
parts = append(parts, fmt.Sprintf("Current Value: %s", currentValue))
702+
}
703+
}
704+
705+
if len(option.AllowedValues) > 0 {
706+
parts = append(parts, fmt.Sprintf("Allowed Values: %s", strings.Join(option.AllowedValues, ", ")))
707+
}
708+
709+
if option.EnvVar != "" {
710+
parts = append(parts, fmt.Sprintf("Environment Variable: %s", option.EnvVar))
711+
}
712+
713+
if option.Example != "" {
714+
parts = append(parts, fmt.Sprintf("Example: %s", option.Example))
715+
}
716+
717+
optionsOutput = append(optionsOutput, strings.Join(parts, "\n"))
718+
}
719+
720+
a.console.Message(ctx, strings.Join(optionsOutput, "\n\n"))
721+
722+
// No UX output
723+
return nil, nil
724+
}

cli/azd/cmd/config_options_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"encoding/json"
10+
"testing"
11+
12+
"github.com/azure/azure-dev/cli/azd/pkg/config"
13+
"github.com/azure/azure-dev/cli/azd/pkg/output"
14+
"github.com/azure/azure-dev/cli/azd/test/mocks"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestConfigOptionsAction_JSON(t *testing.T) {
19+
buf := &bytes.Buffer{}
20+
mockContext := mocks.NewMockContext(context.Background())
21+
console := mockContext.Console
22+
23+
// Create a temporary config file
24+
tempDir := t.TempDir()
25+
configPath := tempDir + "/config.json"
26+
manager := config.NewManager()
27+
fileConfigManager := config.NewFileConfigManager(manager)
28+
userConfigManager := config.NewUserConfigManager(fileConfigManager)
29+
30+
// Save an empty config
31+
err := fileConfigManager.Save(config.NewEmptyConfig(), configPath)
32+
require.NoError(t, err)
33+
34+
action := newConfigOptionsAction(
35+
console,
36+
&output.JsonFormatter{},
37+
buf,
38+
userConfigManager,
39+
[]string{},
40+
)
41+
42+
_, err = action.Run(*mockContext.Context)
43+
require.NoError(t, err)
44+
45+
var options []config.ConfigOption
46+
err = json.Unmarshal(buf.Bytes(), &options)
47+
require.NoError(t, err)
48+
require.NotEmpty(t, options)
49+
50+
// Verify expected options are present
51+
foundDefaults := false
52+
foundAlpha := false
53+
for _, opt := range options {
54+
if opt.Key == "defaults.subscription" {
55+
foundDefaults = true
56+
require.Equal(t, "string", opt.Type)
57+
}
58+
if opt.Key == "alpha.all" {
59+
foundAlpha = true
60+
require.Contains(t, opt.AllowedValues, "on")
61+
require.Contains(t, opt.AllowedValues, "off")
62+
}
63+
}
64+
require.True(t, foundDefaults, "defaults.subscription should be present")
65+
require.True(t, foundAlpha, "alpha.all should be present")
66+
}
67+
68+
func TestConfigOptionsAction_Table(t *testing.T) {
69+
buf := &bytes.Buffer{}
70+
mockContext := mocks.NewMockContext(context.Background())
71+
console := mockContext.Console
72+
73+
tempDir := t.TempDir()
74+
configPath := tempDir + "/config.json"
75+
manager := config.NewManager()
76+
fileConfigManager := config.NewFileConfigManager(manager)
77+
userConfigManager := config.NewUserConfigManager(fileConfigManager)
78+
79+
err := fileConfigManager.Save(config.NewEmptyConfig(), configPath)
80+
require.NoError(t, err)
81+
82+
action := newConfigOptionsAction(
83+
console,
84+
&output.TableFormatter{},
85+
buf,
86+
userConfigManager,
87+
[]string{},
88+
)
89+
90+
_, err = action.Run(*mockContext.Context)
91+
require.NoError(t, err)
92+
93+
outputStr := buf.String()
94+
require.Contains(t, outputStr, "Key")
95+
require.Contains(t, outputStr, "Description")
96+
require.Contains(t, outputStr, "defaults.subscription")
97+
require.Contains(t, outputStr, "alpha.all")
98+
}
99+
100+
func TestConfigOptionsAction_DefaultFormat(t *testing.T) {
101+
buf := &bytes.Buffer{}
102+
mockContext := mocks.NewMockContext(context.Background())
103+
console := mockContext.Console
104+
105+
tempDir := t.TempDir()
106+
configPath := tempDir + "/config.json"
107+
manager := config.NewManager()
108+
fileConfigManager := config.NewFileConfigManager(manager)
109+
userConfigManager := config.NewUserConfigManager(fileConfigManager)
110+
111+
err := fileConfigManager.Save(config.NewEmptyConfig(), configPath)
112+
require.NoError(t, err)
113+
114+
action := newConfigOptionsAction(
115+
console,
116+
&output.NoneFormatter{},
117+
buf,
118+
userConfigManager,
119+
[]string{},
120+
)
121+
122+
_, err = action.Run(*mockContext.Context)
123+
require.NoError(t, err)
124+
125+
// For NoneFormatter, output goes to console.Message(), check the console's output
126+
output := console.Output()
127+
require.NotEmpty(t, output)
128+
outputStr := output[0] // Should be a single message
129+
require.Contains(t, outputStr, "Key: defaults.subscription")
130+
require.Contains(t, outputStr, "Description:")
131+
require.Contains(t, outputStr, "Key: alpha.all")
132+
require.Contains(t, outputStr, "Allowed Values:")
133+
}
134+
135+
func TestConfigOptionsAction_WithCurrentValues(t *testing.T) {
136+
t.Skip("UserConfigManager loads from global config path, making this test complex to set up properly")
137+
// This test would require mocking the global config directory or setting AZD_CONFIG_DIR
138+
// The functionality is better tested through end-to-end tests or manual testing
139+
}

cli/azd/cmd/testdata/TestFigSpec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ const completionSpec: Fig.Spec = {
336336
name: ['list-alpha'],
337337
description: 'Display the list of available features in alpha stage.',
338338
},
339+
{
340+
name: ['options'],
341+
description: 'List all available configuration settings.',
342+
},
339343
{
340344
name: ['reset'],
341345
description: 'Resets configuration to default.',
@@ -1673,6 +1677,10 @@ const completionSpec: Fig.Spec = {
16731677
name: ['list-alpha'],
16741678
description: 'Display the list of available features in alpha stage.',
16751679
},
1680+
{
1681+
name: ['options'],
1682+
description: 'List all available configuration settings.',
1683+
},
16761684
{
16771685
name: ['reset'],
16781686
description: 'Resets configuration to default.',

0 commit comments

Comments
 (0)