From 8c645040b3dc1df4d23b14626735c68e956ba576 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Fri, 13 Feb 2026 19:59:55 -0800 Subject: [PATCH] Add JSON help document generation and command examples Add structured help documentation output as Go structs and JSON for processing into documentation. Includes HelpDoc()/HelpJSON() methods on both FlagSet and Dispatcher, with recursive command tree traversal, flag metadata extraction (including choices via ChoiceProvider interface), positional args, rest args, and flag groups. Also adds Example type and ExampleProvider interface so commands can register usage examples via WithExamples(), exposed through the help document JSON but not rendered in --help output. --- dispatcher.go | 25 ++ dispatcher_test.go | 1 + help_doc.go | 170 ++++++++++++ help_doc_test.go | 645 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 841 insertions(+) create mode 100644 help_doc.go create mode 100644 help_doc_test.go diff --git a/dispatcher.go b/dispatcher.go index 841edd1..4dfcbde 100644 --- a/dispatcher.go +++ b/dispatcher.go @@ -46,6 +46,18 @@ type Command interface { Usage() string } +// ExampleProvider is an interface for commands that provide usage examples. +type ExampleProvider interface { + // Examples returns the examples for this command. + Examples() []Example +} + +// Example represents a usage example for a command. +type Example struct { + Name string // Short description of what the example demonstrates + Body string // The example command line or code +} + // OutputFormatter is an interface for commands that can specify their output format type OutputFormatter interface { // OutputFormat returns the output format for this command @@ -65,6 +77,7 @@ type funcCommand struct { flags *FlagSet handler func(fs *FlagSet, args []string) error usage string + examples []Example outputFormat OutputFormat } @@ -78,6 +91,13 @@ func WithUsage(usage string) CommandOption { } } +// WithExamples sets the usage examples for the command. +func WithExamples(examples ...Example) CommandOption { + return func(c *funcCommand) { + c.examples = examples + } +} + // WithOutputFormat sets the output format for the command func WithOutputFormat(format OutputFormat) CommandOption { return func(c *funcCommand) { @@ -120,6 +140,11 @@ func (c *funcCommand) Usage() string { return c.usage } +// Examples returns the usage examples for this command. +func (c *funcCommand) Examples() []Example { + return c.examples +} + // OutputFormat returns the output format for this command func (c *funcCommand) OutputFormat() OutputFormat { return c.outputFormat diff --git a/dispatcher_test.go b/dispatcher_test.go index 807a18c..5b387fb 100644 --- a/dispatcher_test.go +++ b/dispatcher_test.go @@ -1698,3 +1698,4 @@ func TestDispatcherErrShowHelp(t *testing.T) { assert.Contains(t, output, "Usage: myapp test") }) } + diff --git a/help_doc.go b/help_doc.go new file mode 100644 index 0000000..d6de75b --- /dev/null +++ b/help_doc.go @@ -0,0 +1,170 @@ +package mflags + +import "encoding/json" + +// ChoiceProvider is an interface for Value types that expose a set of valid choices. +// The existing choiceValue type already satisfies this interface. +type ChoiceProvider interface { + Choices() []string +} + +// HelpDocument is the top-level structure for JSON help output from a Dispatcher. +type HelpDocument struct { + Name string `json:"name"` + Commands []CommandDoc `json:"commands"` +} + +// CommandDoc describes a single command in the help document. +type CommandDoc struct { + Path string `json:"path"` + Usage string `json:"usage"` + Flags []FlagDoc `json:"flags"` + FlagGroups []string `json:"flagGroups"` + PositionalArgs []PositionalDoc `json:"positionalArgs,omitempty"` + AcceptsRestArgs bool `json:"acceptsRestArgs,omitempty"` + Examples []ExampleDoc `json:"examples,omitempty"` + Subcommands []CommandDoc `json:"subcommands,omitempty"` +} + +// ExampleDoc describes a usage example in the help document. +type ExampleDoc struct { + Name string `json:"name"` + Body string `json:"body"` +} + +// FlagDoc describes a single flag in the help document. +type FlagDoc struct { + Name string `json:"name"` + Short string `json:"short"` + Type string `json:"type"` + Default string `json:"default,omitempty"` + Usage string `json:"usage"` + Group string `json:"group,omitempty"` + IsBool bool `json:"isBool,omitempty"` + Choices []string `json:"choices,omitempty"` +} + +// PositionalDoc describes a positional argument in the help document. +type PositionalDoc struct { + Name string `json:"name"` + Usage string `json:"usage"` + Type string `json:"type"` +} + +// FlagSetDoc describes a standalone FlagSet's help information. +type FlagSetDoc struct { + Name string `json:"name"` + Flags []FlagDoc `json:"flags"` + FlagGroups []string `json:"flagGroups"` + PositionalArgs []PositionalDoc `json:"positionalArgs"` + AcceptsRestArgs bool `json:"acceptsRestArgs"` +} + +// HelpDoc generates a structured help document for a FlagSet. +func (f *FlagSet) HelpDoc() *FlagSetDoc { + doc := &FlagSetDoc{ + Name: f.name, + Flags: []FlagDoc{}, + FlagGroups: []string{}, + PositionalArgs: []PositionalDoc{}, + AcceptsRestArgs: f.restField != nil, + } + + f.VisitAll(func(flag *Flag) { + fd := FlagDoc{ + Name: flag.Name, + Type: flag.Value.Type(), + Default: flag.DefValue, + Usage: flag.Usage, + Group: flag.Group, + IsBool: flag.Value.IsBool(), + Choices: []string{}, + } + if flag.Short != 0 { + fd.Short = string(flag.Short) + } + if cp, ok := flag.Value.(ChoiceProvider); ok { + fd.Choices = cp.Choices() + } + doc.Flags = append(doc.Flags, fd) + }) + + if len(f.groupOrder) > 0 { + doc.FlagGroups = append(doc.FlagGroups, f.groupOrder...) + } + + for _, pf := range f.GetPositionalFields() { + doc.PositionalArgs = append(doc.PositionalArgs, PositionalDoc{ + Name: pf.Name, + Usage: pf.Usage, + Type: pf.Type.String(), + }) + } + + return doc +} + +// HelpJSON returns the FlagSet's help document as indented JSON. +func (f *FlagSet) HelpJSON() ([]byte, error) { + return json.MarshalIndent(f.HelpDoc(), "", " ") +} + +// HelpDoc generates a structured help document for the entire Dispatcher. +func (d *Dispatcher) HelpDoc() *HelpDocument { + doc := &HelpDocument{ + Name: d.name, + Commands: d.buildCommandDocs(""), + } + return doc +} + +// HelpJSON returns the Dispatcher's help document as indented JSON. +func (d *Dispatcher) HelpJSON() ([]byte, error) { + return json.MarshalIndent(d.HelpDoc(), "", " ") +} + +// buildCommandDocs recursively builds CommandDoc entries for direct children of parentPath. +func (d *Dispatcher) buildCommandDocs(parentPath string) []CommandDoc { + children := d.getDirectChildren(parentPath) + docs := make([]CommandDoc, 0, len(children)) + + for _, child := range children { + cmd := CommandDoc{ + Path: child.Path, + Usage: child.Usage, + Flags: []FlagDoc{}, + FlagGroups: []string{}, + PositionalArgs: []PositionalDoc{}, + AcceptsRestArgs: false, + Subcommands: []CommandDoc{}, + } + + if child.IsEntry { + entry := d.commands[child.Path] + if entry != nil && entry.Command != nil { + fs := entry.Command.FlagSet() + if fs != nil { + fsDoc := fs.HelpDoc() + cmd.Flags = fsDoc.Flags + cmd.FlagGroups = fsDoc.FlagGroups + cmd.PositionalArgs = fsDoc.PositionalArgs + cmd.AcceptsRestArgs = fsDoc.AcceptsRestArgs + } + if ep, ok := entry.Command.(ExampleProvider); ok { + for _, ex := range ep.Examples() { + cmd.Examples = append(cmd.Examples, ExampleDoc{ + Name: ex.Name, + Body: ex.Body, + }) + } + } + } + } + + cmd.Subcommands = d.buildCommandDocs(child.Path) + + docs = append(docs, cmd) + } + + return docs +} diff --git a/help_doc_test.go b/help_doc_test.go new file mode 100644 index 0000000..eaa25e2 --- /dev/null +++ b/help_doc_test.go @@ -0,0 +1,645 @@ +package mflags + +import ( + "encoding/json" + "testing" +) + +func TestFlagSetHelpDoc_BasicFlags(t *testing.T) { + fs := NewFlagSet("test") + fs.String("name", 'n', "default-name", "Set the name") + fs.Bool("verbose", 'v', false, "Enable verbose output") + fs.Int("count", 'c', 5, "Set count") + + doc := fs.HelpDoc() + + if doc.Name != "test" { + t.Errorf("expected name %q, got %q", "test", doc.Name) + } + + if len(doc.Flags) != 3 { + t.Fatalf("expected 3 flags, got %d", len(doc.Flags)) + } + + // Flags are sorted by name via VisitAll + // count, name, verbose + assertFlag(t, doc.Flags[0], "count", "c", "int", "5", "Set count", false) + assertFlag(t, doc.Flags[1], "name", "n", "string", "default-name", "Set the name", false) + assertFlag(t, doc.Flags[2], "verbose", "v", "bool", "false", "Enable verbose output", true) +} + +func TestFlagSetHelpDoc_ChoiceFlags(t *testing.T) { + fs := NewFlagSet("test") + fs.Choice("env", 'e', "staging", []string{"dev", "staging", "prod"}, "Target environment") + + doc := fs.HelpDoc() + + if len(doc.Flags) != 1 { + t.Fatalf("expected 1 flag, got %d", len(doc.Flags)) + } + + f := doc.Flags[0] + if len(f.Choices) != 3 { + t.Fatalf("expected 3 choices, got %d", len(f.Choices)) + } + if f.Choices[0] != "dev" || f.Choices[1] != "staging" || f.Choices[2] != "prod" { + t.Errorf("unexpected choices: %v", f.Choices) + } +} + +func TestFlagSetHelpDoc_PositionalArgs(t *testing.T) { + fs := NewFlagSet("test") + fs.StringPos("target", 0, "", "Deployment target") + fs.IntPos("port", 1, 8080, "Port number") + + doc := fs.HelpDoc() + + if len(doc.PositionalArgs) != 2 { + t.Fatalf("expected 2 positional args, got %d", len(doc.PositionalArgs)) + } + + if doc.PositionalArgs[0].Name != "target" { + t.Errorf("expected positional name %q, got %q", "target", doc.PositionalArgs[0].Name) + } + if doc.PositionalArgs[0].Usage != "Deployment target" { + t.Errorf("expected positional usage %q, got %q", "Deployment target", doc.PositionalArgs[0].Usage) + } + if doc.PositionalArgs[0].Type != "string" { + t.Errorf("expected positional type %q, got %q", "string", doc.PositionalArgs[0].Type) + } + if doc.PositionalArgs[1].Name != "port" { + t.Errorf("expected positional name %q, got %q", "port", doc.PositionalArgs[1].Name) + } + if doc.PositionalArgs[1].Type != "int" { + t.Errorf("expected positional type %q, got %q", "int", doc.PositionalArgs[1].Type) + } +} + +func TestFlagSetHelpDoc_RestArgs(t *testing.T) { + fs := NewFlagSet("test") + var rest []string + fs.Rest(&rest, "remaining arguments") + + doc := fs.HelpDoc() + + if !doc.AcceptsRestArgs { + t.Error("expected AcceptsRestArgs to be true") + } +} + +func TestFlagSetHelpDoc_NoRestArgs(t *testing.T) { + fs := NewFlagSet("test") + + doc := fs.HelpDoc() + + if doc.AcceptsRestArgs { + t.Error("expected AcceptsRestArgs to be false") + } +} + +func TestFlagSetHelpDoc_FlagGroups(t *testing.T) { + fs := NewFlagSet("test") + fs.Group("Advanced") + fs.String("config", 0, "", "Config file path") + fs.Group("Debug") + fs.Bool("trace", 0, false, "Enable tracing") + fs.Group("") + fs.Bool("verbose", 'v', false, "Verbose output") + + doc := fs.HelpDoc() + + if len(doc.FlagGroups) != 2 { + t.Fatalf("expected 2 flag groups, got %d", len(doc.FlagGroups)) + } + if doc.FlagGroups[0] != "Advanced" { + t.Errorf("expected group %q, got %q", "Advanced", doc.FlagGroups[0]) + } + if doc.FlagGroups[1] != "Debug" { + t.Errorf("expected group %q, got %q", "Debug", doc.FlagGroups[1]) + } + + // Verify flags have correct groups + for _, f := range doc.Flags { + switch f.Name { + case "config": + if f.Group != "Advanced" { + t.Errorf("expected config group %q, got %q", "Advanced", f.Group) + } + case "trace": + if f.Group != "Debug" { + t.Errorf("expected trace group %q, got %q", "Debug", f.Group) + } + case "verbose": + if f.Group != "" { + t.Errorf("expected verbose group %q, got %q", "", f.Group) + } + } + } +} + +func TestFlagSetHelpDoc_EmptyFlagSet(t *testing.T) { + fs := NewFlagSet("empty") + + doc := fs.HelpDoc() + + if doc.Name != "empty" { + t.Errorf("expected name %q, got %q", "empty", doc.Name) + } + if len(doc.Flags) != 0 { + t.Errorf("expected 0 flags, got %d", len(doc.Flags)) + } + if len(doc.FlagGroups) != 0 { + t.Errorf("expected 0 flag groups, got %d", len(doc.FlagGroups)) + } + if len(doc.PositionalArgs) != 0 { + t.Errorf("expected 0 positional args, got %d", len(doc.PositionalArgs)) + } + if doc.AcceptsRestArgs { + t.Error("expected AcceptsRestArgs to be false") + } +} + +func TestDispatcherHelpDoc_BasicCommand(t *testing.T) { + d := NewDispatcher("myapp") + + fs := NewFlagSet("deploy") + fs.String("target", 't', "", "Deploy target") + + d.Dispatch("deploy", NewCommand(fs, nil, WithUsage("Deploy the application"))) + + doc := d.HelpDoc() + + if doc.Name != "myapp" { + t.Errorf("expected name %q, got %q", "myapp", doc.Name) + } + if len(doc.Commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(doc.Commands)) + } + + cmd := doc.Commands[0] + if cmd.Path != "deploy" { + t.Errorf("expected path %q, got %q", "deploy", cmd.Path) + } + if cmd.Usage != "Deploy the application" { + t.Errorf("expected usage %q, got %q", "Deploy the application", cmd.Usage) + } + if len(cmd.Flags) != 1 { + t.Fatalf("expected 1 flag, got %d", len(cmd.Flags)) + } + if cmd.Flags[0].Name != "target" { + t.Errorf("expected flag name %q, got %q", "target", cmd.Flags[0].Name) + } +} + +func TestDispatcherHelpDoc_NestedCommands(t *testing.T) { + d := NewDispatcher("myapp") + + fs1 := NewFlagSet("deploy") + d.Dispatch("deploy", NewCommand(fs1, nil, WithUsage("Deploy"))) + + fs2 := NewFlagSet("deploy prod") + fs2.Bool("force", 'f', false, "Force deploy") + d.Dispatch("deploy prod", NewCommand(fs2, nil, WithUsage("Deploy to production"))) + + fs3 := NewFlagSet("deploy staging") + d.Dispatch("deploy staging", NewCommand(fs3, nil, WithUsage("Deploy to staging"))) + + doc := d.HelpDoc() + + if len(doc.Commands) != 1 { + t.Fatalf("expected 1 top-level command, got %d", len(doc.Commands)) + } + + deploy := doc.Commands[0] + if deploy.Path != "deploy" { + t.Errorf("expected path %q, got %q", "deploy", deploy.Path) + } + if len(deploy.Subcommands) != 2 { + t.Fatalf("expected 2 subcommands, got %d", len(deploy.Subcommands)) + } + + // Subcommands are sorted by name + prod := deploy.Subcommands[0] + staging := deploy.Subcommands[1] + + if prod.Path != "deploy prod" { + t.Errorf("expected path %q, got %q", "deploy prod", prod.Path) + } + if prod.Usage != "Deploy to production" { + t.Errorf("expected usage %q, got %q", "Deploy to production", prod.Usage) + } + if len(prod.Flags) != 1 { + t.Fatalf("expected 1 flag on prod, got %d", len(prod.Flags)) + } + if prod.Flags[0].Name != "force" { + t.Errorf("expected flag %q, got %q", "force", prod.Flags[0].Name) + } + + if staging.Path != "deploy staging" { + t.Errorf("expected path %q, got %q", "deploy staging", staging.Path) + } +} + +func TestDispatcherHelpDoc_ImplicitNamespace(t *testing.T) { + d := NewDispatcher("myapp") + + // Register "debug entity list" and "debug entity show" without registering "debug" or "debug entity" + fs1 := NewFlagSet("list") + d.Dispatch("debug entity list", NewCommand(fs1, nil, WithUsage("List entities"))) + + fs2 := NewFlagSet("show") + d.Dispatch("debug entity show", NewCommand(fs2, nil, WithUsage("Show entity details"))) + + doc := d.HelpDoc() + + if len(doc.Commands) != 1 { + t.Fatalf("expected 1 top-level command, got %d", len(doc.Commands)) + } + + debug := doc.Commands[0] + if debug.Path != "debug" { + t.Errorf("expected path %q, got %q", "debug", debug.Path) + } + if debug.Usage != "" { + t.Errorf("expected empty usage for namespace, got %q", debug.Usage) + } + // Namespace should have no flags + if len(debug.Flags) != 0 { + t.Errorf("expected 0 flags for namespace, got %d", len(debug.Flags)) + } + + // Should have "entity" as subcommand (also a namespace) + if len(debug.Subcommands) != 1 { + t.Fatalf("expected 1 subcommand under debug, got %d", len(debug.Subcommands)) + } + + entity := debug.Subcommands[0] + if entity.Path != "debug entity" { + t.Errorf("expected path %q, got %q", "debug entity", entity.Path) + } + if len(entity.Subcommands) != 2 { + t.Fatalf("expected 2 subcommands under entity, got %d", len(entity.Subcommands)) + } + + if entity.Subcommands[0].Path != "debug entity list" { + t.Errorf("expected path %q, got %q", "debug entity list", entity.Subcommands[0].Path) + } + if entity.Subcommands[1].Path != "debug entity show" { + t.Errorf("expected path %q, got %q", "debug entity show", entity.Subcommands[1].Path) + } +} + +func TestDispatcherHelpDoc_EmptyDispatcher(t *testing.T) { + d := NewDispatcher("myapp") + + doc := d.HelpDoc() + + if doc.Name != "myapp" { + t.Errorf("expected name %q, got %q", "myapp", doc.Name) + } + if doc.Commands == nil { + t.Fatal("expected non-nil commands slice") + } + if len(doc.Commands) != 0 { + t.Errorf("expected 0 commands, got %d", len(doc.Commands)) + } +} + +func TestDispatcherHelpJSON_Roundtrip(t *testing.T) { + d := NewDispatcher("myapp") + + fs := NewFlagSet("greet") + fs.String("name", 'n', "world", "Name to greet") + fs.Bool("loud", 'l', false, "Shout the greeting") + fs.Choice("lang", 0, "en", []string{"en", "es", "fr"}, "Language") + fs.StringPos("target", 0, "", "Greeting target") + var rest []string + fs.Rest(&rest, "extra args") + fs.Group("Output") + fs.String("format", 'f', "text", "Output format") + + d.Dispatch("greet", NewCommand(fs, nil, WithUsage("Greet someone"))) + + data, err := d.HelpJSON() + if err != nil { + t.Fatalf("HelpJSON failed: %v", err) + } + + // Verify it's valid JSON by unmarshalling + var roundtrip HelpDocument + if err := json.Unmarshal(data, &roundtrip); err != nil { + t.Fatalf("JSON roundtrip unmarshal failed: %v", err) + } + + if roundtrip.Name != "myapp" { + t.Errorf("roundtrip name: expected %q, got %q", "myapp", roundtrip.Name) + } + if len(roundtrip.Commands) != 1 { + t.Fatalf("roundtrip: expected 1 command, got %d", len(roundtrip.Commands)) + } + + cmd := roundtrip.Commands[0] + if cmd.Path != "greet" { + t.Errorf("roundtrip path: expected %q, got %q", "greet", cmd.Path) + } + if !cmd.AcceptsRestArgs { + t.Error("roundtrip: expected AcceptsRestArgs true") + } + if len(cmd.PositionalArgs) != 1 { + t.Fatalf("roundtrip: expected 1 positional arg, got %d", len(cmd.PositionalArgs)) + } + if cmd.PositionalArgs[0].Name != "target" { + t.Errorf("roundtrip positional: expected %q, got %q", "target", cmd.PositionalArgs[0].Name) + } + + // Verify choices survived roundtrip + var langFlag *FlagDoc + for i := range cmd.Flags { + if cmd.Flags[i].Name == "lang" { + langFlag = &cmd.Flags[i] + break + } + } + if langFlag == nil { + t.Fatal("roundtrip: lang flag not found") + } + if len(langFlag.Choices) != 3 { + t.Errorf("roundtrip: expected 3 choices, got %d", len(langFlag.Choices)) + } +} + +func TestFlagSetHelpJSON(t *testing.T) { + fs := NewFlagSet("standalone") + fs.String("output", 'o', "", "Output file") + + data, err := fs.HelpJSON() + if err != nil { + t.Fatalf("HelpJSON failed: %v", err) + } + + var doc FlagSetDoc + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if doc.Name != "standalone" { + t.Errorf("expected name %q, got %q", "standalone", doc.Name) + } + if len(doc.Flags) != 1 { + t.Fatalf("expected 1 flag, got %d", len(doc.Flags)) + } + if doc.Flags[0].Name != "output" { + t.Errorf("expected flag name %q, got %q", "output", doc.Flags[0].Name) + } + if doc.Flags[0].Short != "o" { + t.Errorf("expected short %q, got %q", "o", doc.Flags[0].Short) + } +} + +func TestDispatcherHelpDoc_CommandWithRestArgs(t *testing.T) { + d := NewDispatcher("myapp") + + fs := NewFlagSet("exec") + var rest []string + fs.Rest(&rest, "command and arguments") + + d.Dispatch("exec", NewCommand(fs, nil, WithUsage("Execute a command"))) + + doc := d.HelpDoc() + + if len(doc.Commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(doc.Commands)) + } + if !doc.Commands[0].AcceptsRestArgs { + t.Error("expected AcceptsRestArgs to be true") + } +} + +func TestFlagSetHelpDoc_EmptyChoices(t *testing.T) { + fs := NewFlagSet("test") + fs.String("name", 0, "", "A name") + + doc := fs.HelpDoc() + + if len(doc.Flags) != 1 { + t.Fatalf("expected 1 flag, got %d", len(doc.Flags)) + } + // Non-choice flags should have empty (not nil) choices slice + if doc.Flags[0].Choices == nil { + t.Error("expected non-nil choices slice") + } + if len(doc.Flags[0].Choices) != 0 { + t.Errorf("expected 0 choices, got %d", len(doc.Flags[0].Choices)) + } +} + +func TestDispatcherHelpDoc_EmptySubarrays(t *testing.T) { + d := NewDispatcher("myapp") + + fs := NewFlagSet("simple") + d.Dispatch("simple", NewCommand(fs, nil, WithUsage("A simple command"))) + + doc := d.HelpDoc() + cmd := doc.Commands[0] + + // Flags and FlagGroups should be non-nil (always present in JSON) + if cmd.Flags == nil { + t.Error("expected non-nil flags") + } + if cmd.FlagGroups == nil { + t.Error("expected non-nil flag groups") + } + + // Verify JSON output: flags/flagGroups always present, omitempty fields omitted + data, err := d.HelpJSON() + if err != nil { + t.Fatalf("HelpJSON failed: %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + cmds := raw["commands"].([]interface{}) + cmdMap := cmds[0].(map[string]interface{}) + + // flags and flagGroups should always be present as empty arrays + for _, field := range []string{"flags", "flagGroups"} { + if cmdMap[field] == nil { + t.Errorf("expected %q to be an empty array in JSON, got null", field) + } + } + + // omitempty fields should be omitted when empty + for _, field := range []string{"positionalArgs", "subcommands", "examples"} { + if _, exists := cmdMap[field]; exists { + t.Errorf("expected %q to be omitted from JSON when empty", field) + } + } +} + +func TestDispatcherHelpDoc_FromStruct(t *testing.T) { + type DeployFlags struct { + Environment string `long:"environment" short:"e" default:"staging" usage:"Target environment" choice:"dev" choice:"staging" choice:"prod"` + Force bool `long:"force" short:"f" usage:"Force deploy"` + } + + d := NewDispatcher("myapp") + + fs := NewFlagSet("deploy") + flags := &DeployFlags{} + if err := fs.FromStruct(flags); err != nil { + t.Fatalf("FromStruct failed: %v", err) + } + + d.Dispatch("deploy", NewCommand(fs, nil, WithUsage("Deploy the app"))) + + doc := d.HelpDoc() + cmd := doc.Commands[0] + + if len(cmd.Flags) != 2 { + t.Fatalf("expected 2 flags, got %d", len(cmd.Flags)) + } + + // Flags sorted: environment, force + envFlag := cmd.Flags[0] + if envFlag.Name != "environment" { + t.Errorf("expected %q, got %q", "environment", envFlag.Name) + } + if envFlag.Short != "e" { + t.Errorf("expected short %q, got %q", "e", envFlag.Short) + } + if envFlag.Default != "staging" { + t.Errorf("expected default %q, got %q", "staging", envFlag.Default) + } + if len(envFlag.Choices) != 3 { + t.Fatalf("expected 3 choices, got %d", len(envFlag.Choices)) + } +} + +func TestDispatcherHelpDoc_Examples(t *testing.T) { + d := NewDispatcher("myapp") + + fs := NewFlagSet("deploy") + fs.String("env", 'e', "staging", "Target environment") + + d.Dispatch("deploy", NewCommand(fs, nil, + WithUsage("Deploy the application"), + WithExamples( + Example{Name: "Deploy to staging", Body: "myapp deploy --env staging"}, + Example{Name: "Deploy to production", Body: "myapp deploy --env prod"}, + ), + )) + + doc := d.HelpDoc() + + if len(doc.Commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(doc.Commands)) + } + + cmd := doc.Commands[0] + if len(cmd.Examples) != 2 { + t.Fatalf("expected 2 examples, got %d", len(cmd.Examples)) + } + + if cmd.Examples[0].Name != "Deploy to staging" { + t.Errorf("expected example name %q, got %q", "Deploy to staging", cmd.Examples[0].Name) + } + if cmd.Examples[0].Body != "myapp deploy --env staging" { + t.Errorf("expected example body %q, got %q", "myapp deploy --env staging", cmd.Examples[0].Body) + } + if cmd.Examples[1].Name != "Deploy to production" { + t.Errorf("expected example name %q, got %q", "Deploy to production", cmd.Examples[1].Name) + } + if cmd.Examples[1].Body != "myapp deploy --env prod" { + t.Errorf("expected example body %q, got %q", "myapp deploy --env prod", cmd.Examples[1].Body) + } +} + +func TestDispatcherHelpDoc_NoExamples(t *testing.T) { + d := NewDispatcher("myapp") + + fs := NewFlagSet("simple") + d.Dispatch("simple", NewCommand(fs, nil, WithUsage("Simple command"))) + + doc := d.HelpDoc() + cmd := doc.Commands[0] + + if cmd.Examples != nil { + t.Errorf("expected nil examples for command without examples, got %v", cmd.Examples) + } + + // Verify it's omitted from JSON (omitempty) + data, err := d.HelpJSON() + if err != nil { + t.Fatalf("HelpJSON failed: %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + cmds := raw["commands"].([]interface{}) + cmdMap := cmds[0].(map[string]interface{}) + + if _, exists := cmdMap["examples"]; exists { + t.Error("expected examples to be omitted from JSON when empty") + } +} + +func TestDispatcherHelpDoc_ExamplesJSONRoundtrip(t *testing.T) { + d := NewDispatcher("myapp") + + fs := NewFlagSet("run") + d.Dispatch("run", NewCommand(fs, nil, + WithUsage("Run a task"), + WithExamples( + Example{Name: "Run all tests", Body: "myapp run --all"}, + ), + )) + + data, err := d.HelpJSON() + if err != nil { + t.Fatalf("HelpJSON failed: %v", err) + } + + var roundtrip HelpDocument + if err := json.Unmarshal(data, &roundtrip); err != nil { + t.Fatalf("JSON roundtrip unmarshal failed: %v", err) + } + + cmd := roundtrip.Commands[0] + if len(cmd.Examples) != 1 { + t.Fatalf("roundtrip: expected 1 example, got %d", len(cmd.Examples)) + } + if cmd.Examples[0].Name != "Run all tests" { + t.Errorf("roundtrip: expected example name %q, got %q", "Run all tests", cmd.Examples[0].Name) + } + if cmd.Examples[0].Body != "myapp run --all" { + t.Errorf("roundtrip: expected example body %q, got %q", "myapp run --all", cmd.Examples[0].Body) + } +} + +// assertFlag is a test helper for common flag assertions. +func assertFlag(t *testing.T, f FlagDoc, name, short, typ, def, usage string, isBool bool) { + t.Helper() + if f.Name != name { + t.Errorf("expected name %q, got %q", name, f.Name) + } + if f.Short != short { + t.Errorf("flag %q: expected short %q, got %q", name, short, f.Short) + } + if f.Type != typ { + t.Errorf("flag %q: expected type %q, got %q", name, typ, f.Type) + } + if f.Default != def { + t.Errorf("flag %q: expected default %q, got %q", name, def, f.Default) + } + if f.Usage != usage { + t.Errorf("flag %q: expected usage %q, got %q", name, usage, f.Usage) + } + if f.IsBool != isBool { + t.Errorf("flag %q: expected isBool %v, got %v", name, isBool, f.IsBool) + } +}