diff --git a/internal/cli/command_contract_test.go b/internal/cli/command_contract_test.go new file mode 100644 index 0000000..f25db6e --- /dev/null +++ b/internal/cli/command_contract_test.go @@ -0,0 +1,240 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCommandHelpSpellingsWriteUsageToStdout(t *testing.T) { + tests := [][]string{nil, {"help"}, {"--help"}, {"-h"}} + for _, args := range tests { + name := "no arguments" + if len(args) > 0 { + name = args[0] + } + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t)) + if code != 0 || !strings.Contains(stdout.String(), "Usage:") || stderr.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + } +} + +func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "unknown command", args: []string{"unknown"}, want: "unknown command"}, + {name: "missing config subcommand", args: []string{"config"}, want: "config requires a subcommand"}, + {name: "unknown pipelines subcommand", args: []string{"pipelines", "unknown"}, want: "unknown pipelines subcommand"}, + {name: "malformed run flag", args: []string{"run", "demo", "--chunk_cache", "invalid"}, want: "not supported"}, + {name: "unknown flag", args: []string{"config", "validate", "--unknown"}, want: "flag provided but not defined"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := RunWithOptions(tt.args, &stdout, &stderr, commandContractOptions(t)) + if code != 2 || !strings.Contains(stderr.String(), tt.want) || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + } +} + +func TestConfigDiscoveryPrefersExplicitPathThenEnvironment(t *testing.T) { + explicit := writeCommandConfig(t, "explicit", "alpha") + environment := writeCommandConfig(t, "environment", "beta") + lookup := func(name string) (string, bool) { + if name == "NOTARIUS_CONFIG" { + return environment, true + } + return "", false + } + + var stdout, stderr bytes.Buffer + code := RunWithOptions([]string{"pipelines", "list", "--config", explicit}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup)) + if code != 0 || stdout.String() != "alpha\nexplicit\n" || stderr.Len() != 0 { + t.Fatalf("explicit config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup)) + if code != 0 || stdout.String() != "beta\nenvironment\n" || stderr.Len() != 0 { + t.Fatalf("environment config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestConfigDiscoveryUsesCompiledDefaultOnlyWhenAvailable(t *testing.T) { + info, statErr := os.Stat(defaultConfigPath) + if statErr != nil && !os.IsNotExist(statErr) { + t.Fatalf("stat compiled default config: %v", statErr) + } + if statErr == nil && !info.Mode().IsRegular() { + t.Skipf("compiled default config has unexpected host state: %s", info.Mode()) + } + + path, err := discoverConfigPath("", commandContractOptions(t)) + if statErr == nil { + if err != nil || path != defaultConfigPath { + t.Fatalf("discoverConfigPath() = %q, %v; want compiled default", path, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), "config file not found") { + t.Fatalf("discoverConfigPath() error = %v, want documented not-found context", err) + } +} + +func TestConfigLoadingFailuresReturnOneWithPathContext(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing.yml") + var stdout, stderr bytes.Buffer + code := RunWithOptions([]string{"config", "validate", "--config", missing}, &stdout, &stderr, commandContractOptions(t)) + if code != 1 || !strings.Contains(stderr.String(), missing) || stdout.Len() != 0 { + t.Fatalf("missing config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + malformed := filepath.Join(t.TempDir(), "malformed.yml") + if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"config", "validate", "--config", malformed}, &stdout, &stderr, commandContractOptions(t)) + if code != 1 || !strings.Contains(stderr.String(), malformed) || !strings.Contains(stderr.String(), "parse config file") || stdout.Len() != 0 { + t.Fatalf("malformed config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestConfigValidateResolvesPipelineAndChecksSelection(t *testing.T) { + configPath := writeResolvableCommandConfig(t) + options := commandContractOptions(t) + + var stdout, stderr bytes.Buffer + code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells"}, &stdout, &stderr, options) + if code != 0 || !strings.Contains(stdout.String(), "valid for pipeline \"demo\"") || stderr.Len() != 0 { + t.Fatalf("valid resolution: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "missing"}, &stdout, &stderr, options) + if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") { + t.Fatalf("unknown pipeline: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "missing"}, &stdout, &stderr, options) + if code != 1 || !strings.Contains(stderr.String(), "lane \"missing\"") { + t.Fatalf("unknown lane: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "spells"}, &stdout, &stderr, options) + if code != 2 || !strings.Contains(stderr.String(), "--only requires --pipeline") { + t.Fatalf("missing pipeline for only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells,,other"}, &stdout, &stderr, options) + if code != 2 || !strings.Contains(stderr.String(), "--only must contain") { + t.Fatalf("malformed only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestPipelinesListSortsNormalizedIDsInTextAndJSON(t *testing.T) { + configPath := writeCommandConfig(t, " zeta ", "alpha") + options := commandContractOptions(t) + var stdout, stderr bytes.Buffer + code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, options) + if code != 0 || stdout.String() != "alpha\nzeta\n" || stderr.Len() != 0 { + t.Fatalf("text list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, options) + var payload struct { + Pipelines []string `json:"pipelines"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("JSON list = %q: %v", stdout.String(), err) + } + if code != 0 || len(payload.Pipelines) != 2 || payload.Pipelines[0] != "alpha" || payload.Pipelines[1] != "zeta" || stderr.Len() != 0 { + t.Fatalf("JSON list: code=%d payload=%#v stderr=%q", code, payload, stderr.String()) + } +} + +func TestRemovedStructuralFlagsAndRuntimeFailuresKeepExitClasses(t *testing.T) { + configPath := writeResolvableCommandConfig(t) + options := commandContractOptions(t) + + var stdout, stderr bytes.Buffer + code := RunWithOptions([]string{"run", "demo", "--input", "missing-input", "--config", configPath, "--diagnostics-dir", t.TempDir()}, &stdout, &stderr, options) + if code != 2 || !strings.Contains(stderr.String(), "flag provided but not defined") { + t.Fatalf("removed flag: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + code = RunWithOptions([]string{"run", "missing", "--input", "missing-input", "--config", configPath, "--chunk_cache", "bypass"}, &stdout, &stderr, options) + if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") || stdout.Len() != 0 { + t.Fatalf("valid-runtime failure: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func commandContractOptions(t *testing.T) Options { + return commandContractOptionsWithLookup(t, emptyLookup) +} + +func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string, bool)) Options { + t.Helper() + components, err := newProductionComponents() + if err != nil { + t.Fatal(err) + } + return Options{ + Catalog: catalogFromRegistries(components.registries), + Registries: components.registries, + LookupEnv: lookup, + } +} + +func writeCommandConfig(t *testing.T, firstID, secondID string) string { + t.Helper() + content := fmt.Sprintf("version: 3\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID) + return writeCommandConfigContent(t, content) +} + +func writeResolvableCommandConfig(t *testing.T) string { + t.Helper() + return writeCommandConfigContent(t, `version: 3 +pipelines: + demo: + input: seriatim + artifacts: + spells: + extract: dnd/spells +`) +} + +func writeCommandConfigContent(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +}