mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
01fba76bfc
38 commands carried an 'Examples:' block inside Long. They now set Example, which cobra renders under its own heading in --help and cmdhelp reads first. cmdhelp's Example parser now drops a trailing '# comment' the way the Long parser always did, so the move changes no emitted example: a before/after diff of 'pad help --format json' over all 169 commands shows identical examples, identical args/flags/summary, and descriptions that differ only by the removed block. TestRealPadTree_ExamplesLiveInTheExampleField keeps Long free of example blocks. Examples for the leaf commands that have none follow per group.
367 lines
13 KiB
Go
367 lines
13 KiB
Go
package cmdhelp
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestShellSplit_BasicCases(t *testing.T) {
|
|
cases := map[string][]string{
|
|
`pad item create task "Fix OAuth"`: {"pad", "item", "create", "task", "Fix OAuth"},
|
|
`pad item create idea 'one two'`: {"pad", "item", "create", "idea", "one two"},
|
|
`pad foo --bar=baz qux`: {"pad", "foo", "--bar=baz", "qux"},
|
|
`pad x \"escaped\"`: {"pad", "x", `"escaped"`},
|
|
`pad item create`: {"pad", "item", "create"},
|
|
}
|
|
for in, want := range cases {
|
|
got, err := shellSplit(in)
|
|
if err != nil {
|
|
t.Errorf("shellSplit(%q) error: %v", in, err)
|
|
continue
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("shellSplit(%q) = %v (len %d), want %v (len %d)", in, got, len(got), want, len(want))
|
|
continue
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("shellSplit(%q)[%d] = %q, want %q", in, i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShellSplit_UnterminatedQuotesError(t *testing.T) {
|
|
if _, err := shellSplit(`pad "unterminated`); err == nil {
|
|
t.Errorf("expected error for unterminated double quote")
|
|
}
|
|
if _, err := shellSplit(`pad 'unterminated`); err == nil {
|
|
t.Errorf("expected error for unterminated single quote")
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_Clean(t *testing.T) {
|
|
// Build a tree where the example references real commands and flags.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
root.PersistentFlags().String("workspace", "", "workspace override")
|
|
|
|
create := &cobra.Command{
|
|
Use: "create <coll>",
|
|
Short: "create item",
|
|
Example: ` padtest item create task --priority high`,
|
|
}
|
|
create.Flags().String("priority", "", "priority")
|
|
|
|
item := &cobra.Command{Use: "item", Short: "item group"}
|
|
item.AddCommand(create)
|
|
root.AddCommand(item)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) != 0 {
|
|
t.Errorf("expected no findings, got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_DetectsTypoFlag(t *testing.T) {
|
|
// THIS IS THE DRIFT-PREVENTION CONTRACT TEST (spec §6 / §11 Q5).
|
|
// A typo in an example flag — `--priorty` instead of `--priority`
|
|
// — MUST be detected by ValidateExamples and reported with the
|
|
// offending command + example index.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --priorty high`, // typo
|
|
}
|
|
leaf.Flags().String("priority", "", "priority")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
|
|
if len(findings) == 0 {
|
|
t.Fatalf("expected at least one finding for --priorty typo, got none")
|
|
}
|
|
want := []string{"leaf", "example[0]", "priorty"}
|
|
for _, w := range want {
|
|
if !strings.Contains(findings[0], w) {
|
|
t.Errorf("finding should mention %q for triage; got: %q", w, findings[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_DetectsUnknownCommand(t *testing.T) {
|
|
// Example references a command path that doesn't exist on the tree.
|
|
// Common when a command gets renamed/deleted but the example block
|
|
// in Long stays stale.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest does-not-exist --foo bar`,
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) == 0 {
|
|
t.Errorf("expected finding for unknown command 'does-not-exist'")
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_AcceptsNegateFlag(t *testing.T) {
|
|
// `--no-cache` form must be accepted when the underlying flag is
|
|
// `cache` (negation per spec §5.3).
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --no-cache`,
|
|
}
|
|
leaf.Flags().Bool("cache", true, "use cache")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) != 0 {
|
|
t.Errorf("--no-cache should resolve via negate-flag rule, got findings: %v", findings)
|
|
}
|
|
}
|
|
|
|
// TestValidateExamples_DoubleDashTerminatesFlagScan pins the fix for the
|
|
// "playbook match" example drift found in CI on a2a5a853: a positional
|
|
// argument after "--" that merely LOOKS like a flag (e.g. a dash-led text
|
|
// arg, `-- "-ship it"`) must never be reported as an unknown flag. Cobra
|
|
// itself stops flag parsing at "--", so the validator must too.
|
|
func TestValidateExamples_DoubleDashTerminatesFlagScan(t *testing.T) {
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf -- "-ship it"`,
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) != 0 {
|
|
t.Errorf("a dash-led positional after -- should not be reported as an unknown flag; got: %v", findings)
|
|
}
|
|
}
|
|
|
|
// TestValidateExamples_DoubleDashDoesNotHideEarlierDrift is the companion
|
|
// leg: the scan must still catch a genuinely unknown flag that appears
|
|
// BEFORE the "--" terminator. The break the fix introduces stops the scan
|
|
// AT "--", not before it — this pins that the break sits in the right
|
|
// place rather than swallowing everything in the example.
|
|
func TestValidateExamples_DoubleDashDoesNotHideEarlierDrift(t *testing.T) {
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --bogus -- "-ship it"`,
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) == 0 {
|
|
t.Fatal("expected a finding for --bogus, which appears BEFORE the -- terminator; got none")
|
|
}
|
|
if !strings.Contains(findings[0], "bogus") {
|
|
t.Errorf("finding should mention the unknown flag %q; got: %q", "bogus", findings[0])
|
|
}
|
|
}
|
|
|
|
// TestValidateExamples_LoneDashDoesNotTerminateScan covers the third leg:
|
|
// a LONE "-" (the stdin convention, e.g. `pad item import -`) is a
|
|
// different token from "--" and must not stop the scan — an unknown flag
|
|
// after it is still caught.
|
|
func TestValidateExamples_LoneDashDoesNotTerminateScan(t *testing.T) {
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf - --bogus`,
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) == 0 {
|
|
t.Fatal("expected a finding for --bogus after a lone \"-\"; a lone dash must not act as a terminator")
|
|
}
|
|
if !strings.Contains(findings[0], "bogus") {
|
|
t.Errorf("finding should mention the unknown flag %q; got: %q", "bogus", findings[0])
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_FlagBeforeSubcommandResolvesToCorrectTarget(t *testing.T) {
|
|
// Cobra accepts flags interleaved with the command path — e.g.
|
|
// `pad --workspace foo item create task --priority high`.
|
|
// The validator MUST resolve to `item create` (not root) so leaf
|
|
// flags like --priority are checked against the leaf, not just
|
|
// against root's flag set. (Caught by Codex round 1.)
|
|
root := &cobra.Command{Use: "padtest"}
|
|
root.PersistentFlags().String("workspace", "", "workspace override")
|
|
|
|
create := &cobra.Command{
|
|
Use: "create <coll>",
|
|
Short: "create",
|
|
Example: ` padtest --workspace foo item create task --priority high`,
|
|
}
|
|
create.Flags().String("priority", "", "priority")
|
|
|
|
item := &cobra.Command{Use: "item", Short: "item group"}
|
|
item.AddCommand(create)
|
|
root.AddCommand(item)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateExamples(doc, root); len(findings) != 0 {
|
|
t.Errorf("flag-before-subcommand example should resolve cleanly; got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_AcceptsPersistentFlagFromAncestor(t *testing.T) {
|
|
// `--workspace` is a persistent root flag; it must be accepted on
|
|
// any subcommand example.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
root.PersistentFlags().String("workspace", "", "workspace override")
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --workspace foo`,
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateExamples(doc, root); len(findings) != 0 {
|
|
t.Errorf("persistent root flag should resolve on subcommand, got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_SkipsNonBinaryExamples(t *testing.T) {
|
|
// Documentation snippets that aren't pad invocations (e.g.
|
|
// `cat ~/foo.json | jq` to show output) are skipped, not flagged.
|
|
// The drift contract is for pad-invocation drift specifically;
|
|
// non-pad lines are docs prose.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` cat ~/.padtest/foo.json | jq
|
|
padtest leaf --real-flag`,
|
|
}
|
|
leaf.Flags().Bool("real-flag", false, "real flag")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateExamples(doc, root)
|
|
if len(findings) != 0 {
|
|
t.Errorf("non-pad example should be skipped silently; got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestValidateExamples_PipelineStopsAtFirstCommand(t *testing.T) {
|
|
// `padtest leaf --foo | jq -r .x` — the validator only checks the
|
|
// first command in the pipeline. The `-r .x` after the pipe
|
|
// belongs to jq, not padtest, and would erroneously flag if we
|
|
// kept tokenizing past `|`.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --foo | jq -r .x`,
|
|
}
|
|
leaf.Flags().Bool("foo", false, "foo flag")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateExamples(doc, root); len(findings) != 0 {
|
|
t.Errorf("pipeline should be honored; got: %v", findings)
|
|
}
|
|
}
|
|
|
|
func TestParseExamplesFromLong_StripsSameLineComments(t *testing.T) {
|
|
// Pad's existing convention: trailing `# annotation` on an example
|
|
// line. Should be stripped before recording the example.
|
|
long := `Examples:
|
|
pad attachment list --item TASK-5 # one item's attachments
|
|
pad attachment list --category image # filter by category`
|
|
|
|
got := parseExamplesFromLong(long)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 examples, got %d: %+v", len(got), got)
|
|
}
|
|
if strings.Contains(got[0].Cmd, "#") {
|
|
t.Errorf("trailing comment leaked into example[0]: %q", got[0].Cmd)
|
|
}
|
|
if got[0].Cmd != "pad attachment list --item TASK-5" {
|
|
t.Errorf("example[0] = %q, want stripped form", got[0].Cmd)
|
|
}
|
|
}
|
|
|
|
// TASK-2865: the Example field reads a block the way Long does, so a block
|
|
// moved from Long to Example emits the same examples. A `#` inside quotes is
|
|
// a literal, not a comment.
|
|
func TestParseExamples_MatchesLongForTheSameBlock(t *testing.T) {
|
|
block := ` pad attachment list --item TASK-5 # one item's attachments
|
|
pad item search "#hashtag" # quoted hash stays
|
|
# a whole-line comment
|
|
pad item list`
|
|
|
|
fromExample := parseExamples(block)
|
|
fromLong := parseExamplesFromLong("Examples:\n" + block)
|
|
if len(fromExample) != 3 {
|
|
t.Fatalf("expected 3 examples, got %d: %+v", len(fromExample), fromExample)
|
|
}
|
|
if fmt.Sprint(fromExample) != fmt.Sprint(fromLong) {
|
|
t.Errorf("Example and Long disagree on the same block:\nExample: %+v\nLong: %+v", fromExample, fromLong)
|
|
}
|
|
if fromExample[1].Cmd != `pad item search "#hashtag"` {
|
|
t.Errorf("a quoted # must survive: %q", fromExample[1].Cmd)
|
|
}
|
|
}
|
|
|
|
func TestValidateBoolArity_DetectsValuedBool(t *testing.T) {
|
|
// Spec §5.3: bool flags MUST be presence-only. An example using
|
|
// `--cache=true` violates the contract.
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --cache=true`,
|
|
}
|
|
leaf.Flags().Bool("cache", false, "cache")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
findings := ValidateBoolArity(doc)
|
|
if len(findings) == 0 {
|
|
t.Errorf("expected violation for --cache=true on bool flag")
|
|
}
|
|
if len(findings) > 0 && !strings.Contains(findings[0], "cache") {
|
|
t.Errorf("finding should mention the offending flag: %q", findings[0])
|
|
}
|
|
}
|
|
|
|
func TestValidateBoolArity_AcceptsPresenceForm(t *testing.T) {
|
|
root := &cobra.Command{Use: "padtest"}
|
|
leaf := &cobra.Command{
|
|
Use: "leaf",
|
|
Short: "leaf",
|
|
Example: ` padtest leaf --cache`,
|
|
}
|
|
leaf.Flags().Bool("cache", false, "cache")
|
|
root.AddCommand(leaf)
|
|
|
|
doc := Build(root, root, Options{Binary: "padtest", MaxDepth: -1})
|
|
if findings := ValidateBoolArity(doc); len(findings) != 0 {
|
|
t.Errorf("--cache (presence form) should be accepted, got: %v", findings)
|
|
}
|
|
}
|