Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 96 additions & 23 deletions cmd/help_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,69 @@ type jsonHelpInput struct {
}

type jsonCommandManifest struct {
Path string `json:"path"`
Use string `json:"use"`
Short string `json:"short"`
Aliases []string `json:"aliases"`
Runnable bool `json:"runnable"`
Flags []jsonCommandFlag `json:"flags"`
SupportsStructuredOutput bool `json:"supports_structured_output"`
AuthModes []string `json:"auth_modes"`
DestructiveLevel string `json:"destructive_level"`
Path string `json:"path"`
Use string `json:"use"`
Short string `json:"short"`
Aliases []string `json:"aliases"`
Runnable bool `json:"runnable"`
Flags []jsonCommandFlag `json:"flags"`
SupportsStructuredOutput bool `json:"supports_structured_output"`
AuthModes []string `json:"auth_modes"`
DestructiveLevel string `json:"destructive_level"`
ManifestVersion string `json:"manifest_version"`
Args []jsonCommandArg `json:"args"`
Examples []jsonCommandExample `json:"examples"`
SchemaRefs jsonCommandSchemaRefs `json:"schema_refs"`
DropboxScopes []string `json:"dropbox_scopes"`
ScopeAccuracy string `json:"scope_accuracy"`
StdinStdout jsonCommandStdinStdout `json:"stdin_stdout"`
ResultStatuses []string `json:"result_statuses"`
ResultKinds []string `json:"result_kinds"`
WarningCodes []string `json:"warning_codes"`
MayPrompt bool `json:"may_prompt"`
}

type jsonCommandFlag struct {
Name string `json:"name"`
Type string `json:"type"`
Default string `json:"default"`
Usage string `json:"usage"`
Inherited bool `json:"inherited"`
Name string `json:"name"`
Type string `json:"type"`
Default string `json:"default"`
Usage string `json:"usage"`
Inherited bool `json:"inherited"`
Shorthand string `json:"shorthand"`
EnumValues []string `json:"enum_values"`
Conflicts []string `json:"conflicts"`
Required bool `json:"required"`
Sensitive bool `json:"sensitive"`
MayPrompt bool `json:"may_prompt"`
ValueKind string `json:"value_kind"`
}

type jsonCommandArg struct {
Name string `json:"name"`
Required bool `json:"required"`
Variadic bool `json:"variadic"`
Placement string `json:"placement"`
ValueKind string `json:"value_kind"`
Description string `json:"description"`
StreamDash bool `json:"stream_dash"`
}

type jsonCommandExample struct {
Description string `json:"description"`
Command string `json:"command"`
}

type jsonCommandSchemaRefs struct {
SuccessSchema string `json:"success_schema"`
ErrorSchema string `json:"error_schema"`
CommandContract string `json:"command_contract,omitempty"`
}

type jsonCommandStdinStdout struct {
ReadsStdin bool `json:"reads_stdin"`
WritesBinaryStdout bool `json:"writes_binary_stdout"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
}

func installJSONHelp(root *cobra.Command) {
Expand Down Expand Up @@ -263,17 +309,31 @@ func publicCommandSubtree(cmd *cobra.Command) []*cobra.Command {
func jsonCommandManifestFor(cmd *cobra.Command) jsonCommandManifest {
cmd.InitDefaultHelpFlag()
cmd.InitDefaultVersionFlag()
path := jsonManifestCommandPath(cmd)
meta := commandManifestMetadataFor(path)
supportsStructuredOutput := commandSupportsStructuredOutput(cmd)

return jsonCommandManifest{
Path: jsonManifestCommandPath(cmd),
Path: path,
Use: cmd.UseLine(),
Short: cmd.Short,
Aliases: sortedCopyStringSlice(cmd.Aliases),
Runnable: cmd.Runnable(),
Flags: jsonCommandFlags(cmd),
SupportsStructuredOutput: commandSupportsStructuredOutput(cmd),
Flags: jsonCommandFlags(cmd, meta.Flags),
SupportsStructuredOutput: supportsStructuredOutput,
AuthModes: commandManifestAuthModes(cmd),
DestructiveLevel: commandManifestDestructiveLevel(cmd),
ManifestVersion: commandManifestVersion,
Args: normalizeJSONCommandArgs(meta.Args),
Examples: normalizeJSONCommandExamples(meta.Examples),
SchemaRefs: commandManifestSchemaRefs(path, supportsStructuredOutput),
DropboxScopes: sortedCopyStringSlice(meta.DropboxScopes),
ScopeAccuracy: commandManifestScopeAccuracy(meta),
StdinStdout: commandManifestStdinStdout(meta),
ResultStatuses: sortedCopyStringSlice(meta.ResultStatuses),
ResultKinds: sortedCopyStringSlice(meta.ResultKinds),
WarningCodes: sortedCopyStringSlice(meta.WarningCodes),
MayPrompt: meta.MayPrompt,
}
}

Expand All @@ -287,7 +347,7 @@ func jsonManifestCommandPath(cmd *cobra.Command) string {
return jsonCommandPath(cmd)
}

func jsonCommandFlags(cmd *cobra.Command) []jsonCommandFlag {
func jsonCommandFlags(cmd *cobra.Command, metadata map[string]jsonCommandFlagMetadata) []jsonCommandFlag {
flagsByName := make(map[string]jsonCommandFlag)
addFlags := func(flags *pflag.FlagSet, inherited bool) {
if flags == nil {
Expand All @@ -304,12 +364,20 @@ func jsonCommandFlags(cmd *cobra.Command) []jsonCommandFlag {
if flag.Value != nil {
flagType = flag.Value.Type()
}
flagMeta := metadata[flag.Name]
flagsByName[flag.Name] = jsonCommandFlag{
Name: flag.Name,
Type: flagType,
Default: flag.DefValue,
Usage: flag.Usage,
Inherited: inherited,
Name: flag.Name,
Type: flagType,
Default: flag.DefValue,
Usage: flag.Usage,
Inherited: inherited,
Shorthand: flag.Shorthand,
EnumValues: sortedCopyStringSlice(flagMeta.EnumValues),
Conflicts: sortedCopyStringSlice(flagMeta.Conflicts),
Required: flagMeta.Required,
Sensitive: flagMeta.Sensitive,
MayPrompt: flagMeta.MayPrompt,
ValueKind: commandManifestFlagValueKind(flag, flagMeta),
}
})
}
Expand All @@ -330,6 +398,11 @@ func jsonCommandFlags(cmd *cobra.Command) []jsonCommandFlag {
return result
}

// CommandManifestFor returns the JSON help manifest for a command.
func CommandManifestFor(cmd *cobra.Command) jsonCommandManifest {
return jsonCommandManifestFor(cmd)
}

func setCommandAuthModes(cmd *cobra.Command, modes ...string) {
setCommandAnnotationList(cmd, commandAuthModesAnnotation, modes)
}
Expand Down
160 changes: 160 additions & 0 deletions cmd/help_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
Expand Down Expand Up @@ -256,6 +257,146 @@ func TestJSONHelpManifestFields(t *testing.T) {
}
}

func TestJSONHelpManifestV1MachineFields(t *testing.T) {
put := jsonCommandManifestFor(putCmd)
if put.ManifestVersion != commandManifestVersion {
t.Fatalf("manifest_version = %q, want %q", put.ManifestVersion, commandManifestVersion)
}
assertJSONHelpArg(t, put.Args, "source", true, "local_path", true)
assertJSONHelpArg(t, put.Args, "target", false, "dropbox_path", false)
assertStringSliceEqual(t, "put --if-exists enum", jsonHelpFlagByName(t, put.Flags, "if-exists").EnumValues, []string{"overwrite", "skip", "fail"})
if !put.StdinStdout.ReadsStdin || put.StdinStdout.WritesBinaryStdout {
t.Fatalf("put stdin_stdout = %+v, want stdin only", put.StdinStdout)
}
assertStringSliceEqual(t, "put warning codes", put.WarningCodes, []string{jsonWarningCodeSkippedSymlink})
assertStringSliceEqual(t, "put result statuses", put.ResultStatuses, []string{"created", "existing", "skipped", "uploaded"})
assertStringSliceEqual(t, "put result kinds", put.ResultKinds, []string{"file", "folder"})
assertStringSliceEqual(t, "put scopes", put.DropboxScopes, []string{"files.content.write", "files.metadata.read"})
if put.ScopeAccuracy != commandManifestScopeAccuracyBestEffort {
t.Fatalf("scope_accuracy = %q, want %q", put.ScopeAccuracy, commandManifestScopeAccuracyBestEffort)
}
if put.SchemaRefs.CommandContract != "docs/json-schema/v1/commands.json#/commands/put" {
t.Fatalf("put command_contract = %q", put.SchemaRefs.CommandContract)
}
if len(put.Examples) == 0 {
t.Fatal("put examples = empty, want examples")
}
}

func TestJSONHelpManifestV1SelectedCommandMetadata(t *testing.T) {
get := jsonCommandManifestFor(getCmd)
assertJSONHelpArg(t, get.Args, "source", true, "dropbox_path", false)
assertJSONHelpArg(t, get.Args, "target", false, "local_path", true)
if !get.StdinStdout.WritesBinaryStdout {
t.Fatalf("get stdin_stdout = %+v, want binary stdout support", get.StdinStdout)
}

cp := jsonCommandManifestFor(cpCmd)
assertJSONHelpArg(t, cp.Args, "source", true, "dropbox_path", false)
if !jsonHelpArgByName(t, cp.Args, "source").Variadic {
t.Fatal("cp source variadic = false, want true")
}
assertStringSliceEqual(t, "cp --if-exists enum", jsonHelpFlagByName(t, cp.Flags, "if-exists").EnumValues, []string{"fail", "skip"})

create := jsonCommandManifestFor(shareLinkCreateCmd)
assertStringSliceEqual(t, "share-link create audience enum", jsonHelpFlagByName(t, create.Flags, "audience").EnumValues, []string{"public", "team", "members", "no-one"})
assertStringSliceEqual(t, "share-link create allow conflict", jsonHelpFlagByName(t, create.Flags, "allow-download").Conflicts, []string{"disallow-download"})
if !jsonHelpFlagByName(t, create.Flags, "password").Sensitive {
t.Fatal("share-link create password sensitive = false, want true")
}
if !jsonHelpFlagByName(t, create.Flags, "password-prompt").MayPrompt {
t.Fatal("share-link create password-prompt may_prompt = false, want true")
}

update := jsonCommandManifestFor(shareLinkUpdateCmd)
assertStringSliceEqual(t, "share-link update expires conflict", jsonHelpFlagByName(t, update.Flags, "expires").Conflicts, []string{"remove-expiration"})
assertStringSliceEqual(t, "share-link update remove-password conflict", jsonHelpFlagByName(t, update.Flags, "remove-password").Conflicts, []string{"password", "password-file", "password-prompt"})

deprecatedListLink := jsonCommandManifestFor(shareListLinksCmd)
if !strings.Contains(deprecatedListLink.Use, "[path]") {
t.Fatalf("share list link use = %q, want optional path", deprecatedListLink.Use)
}
assertJSONHelpArg(t, deprecatedListLink.Args, "path", false, "dropbox_path", false)

login := jsonCommandManifestFor(loginCmd)
if !login.MayPrompt {
t.Fatal("login may_prompt = false, want true")
}
if jsonHelpFlagByName(t, login.Flags, "app-key").ValueKind != "dropbox_app_key" {
t.Fatalf("login app-key value_kind = %q", jsonHelpFlagByName(t, login.Flags, "app-key").ValueKind)
}

completion := jsonCommandManifestFor(newCompletionCmd())
if completion.SupportsStructuredOutput {
t.Fatal("completion supports_structured_output = true, want false")
}
if len(completion.AuthModes) != 0 || len(completion.DropboxScopes) != 0 {
t.Fatalf("completion auth/scopes = %v/%v, want none", completion.AuthModes, completion.DropboxScopes)
}
}

func TestJSONHelpManifestRegistryAudit(t *testing.T) {
RootCmd.InitDefaultHelpCmd()

for _, command := range publicCommandSubtree(RootCmd) {
path := jsonCommandManifestFor(command).Path
if command.Runnable() {
if !commandManifestRegistry[path].Known {
t.Errorf("runnable command %q has no command manifest registry entry", path)
}
}

manifest := jsonCommandManifestFor(command)
flagNames := make(map[string]bool)
for _, flag := range manifest.Flags {
flagNames[flag.Name] = true
}
if registry := commandManifestRegistry[path]; registry.Known {
for name, flagMeta := range registry.Flags {
if !flagNames[name] {
t.Errorf("%s has registry metadata for unknown flag --%s", path, name)
}
for _, conflict := range flagMeta.Conflicts {
if !flagNames[conflict] {
t.Errorf("%s registry metadata for --%s conflicts with unknown flag --%s", path, name, conflict)
}
}
}
}
for _, flag := range manifest.Flags {
for _, conflict := range flag.Conflicts {
if !flagNames[conflict] {
t.Errorf("%s --%s conflicts with unknown flag --%s", path, flag.Name, conflict)
}
}
}
if manifest.SchemaRefs.SuccessSchema != commandManifestSuccessSchema {
t.Errorf("%s success schema = %q", path, manifest.SchemaRefs.SuccessSchema)
}
if _, err := os.Stat(filepath.Join("..", manifest.SchemaRefs.SuccessSchema)); err != nil {
t.Errorf("%s success schema %q is not readable: %v", path, manifest.SchemaRefs.SuccessSchema, err)
}
if manifest.SchemaRefs.ErrorSchema != commandManifestErrorSchema {
t.Errorf("%s error schema = %q", path, manifest.SchemaRefs.ErrorSchema)
}
if _, err := os.Stat(filepath.Join("..", manifest.SchemaRefs.ErrorSchema)); err != nil {
t.Errorf("%s error schema %q is not readable: %v", path, manifest.SchemaRefs.ErrorSchema, err)
}
if manifest.SupportsStructuredOutput && manifest.SchemaRefs.CommandContract == "" {
t.Errorf("%s supports structured output but has no command contract ref", path)
}
if manifest.SchemaRefs.CommandContract != "" {
wantPrefix := commandManifestContractFile + "#/commands/"
if !strings.HasPrefix(manifest.SchemaRefs.CommandContract, wantPrefix) {
t.Errorf("%s command contract = %q, want prefix %q", path, manifest.SchemaRefs.CommandContract, wantPrefix)
}
if _, err := os.Stat(filepath.Join("..", commandManifestContractFile)); err != nil {
t.Errorf("%s command contract file %q is not readable: %v", path, commandManifestContractFile, err)
}
}
}
}

func TestJSONHelpIsDeterministic(t *testing.T) {
first, _, err := executeJSONHelpTestRoot(t, []string{"--help", "--output=json"})
if err != nil {
Expand Down Expand Up @@ -722,6 +863,25 @@ func jsonHelpFlagByName(t *testing.T, flags []jsonCommandFlag, name string) json
return jsonCommandFlag{}
}

func assertJSONHelpArg(t *testing.T, args []jsonCommandArg, name string, required bool, valueKind string, streamDash bool) {
t.Helper()
arg := jsonHelpArgByName(t, args, name)
if arg.Required != required || arg.ValueKind != valueKind || arg.StreamDash != streamDash {
t.Fatalf("arg %s = %+v, want required=%t value_kind=%s stream_dash=%t", name, arg, required, valueKind, streamDash)
}
}

func jsonHelpArgByName(t *testing.T, args []jsonCommandArg, name string) jsonCommandArg {
t.Helper()
for _, arg := range args {
if arg.Name == name {
return arg
}
}
t.Fatalf("arg %q not found", name)
return jsonCommandArg{}
}

func sortStrings(values []string) {
sort.Strings(values)
}
Loading
Loading