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: 4\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: 4 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 }