package cli import ( "bytes" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) func TestRunNoArgsWritesUsageToStdout(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Run(nil, &stdout, &stderr) if code != 0 { t.Fatalf("Run() code = %d, want 0", code) } if stdout.String() != usage { t.Fatalf("stdout = %q, want %q", stdout.String(), usage) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunHelpArgsWriteUsageToStdout(t *testing.T) { tests := []struct { name string args []string }{ {name: "help", args: []string{"help"}}, {name: "long help flag", args: []string{"--help"}}, {name: "short help flag", args: []string{"-h"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Run(tt.args, &stdout, &stderr) if code != 0 { t.Fatalf("Run() code = %d, want 0", code) } if stdout.String() != usage { t.Fatalf("stdout = %q, want %q", stdout.String(), usage) } if !strings.Contains(stdout.String(), "config validate") || !strings.Contains(stdout.String(), "pipelines list") { t.Fatalf("usage does not mention new commands: %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } }) } } func TestRunUnknownCommandWritesErrorAndUsageToStderr(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Run([]string{"extract"}, &stdout, &stderr) if code != 2 { t.Fatalf("Run() code = %d, want 2", code) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } gotStderr := stderr.String() if !strings.Contains(gotStderr, "notarius: unknown command \"extract\"") { t.Fatalf("stderr = %q, want unknown command error", gotStderr) } if !strings.Contains(gotStderr, usage) { t.Fatalf("stderr = %q, want usage", gotStderr) } } func TestRunConfigValidateSuccessWithFakeCatalog(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "is valid for pipeline") { t.Fatalf("stdout = %q, want validation success", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunConfigValidateReportsParseErrors(t *testing.T) { configPath := writeFile(t, "config.yml", "version: 2\n") var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{}) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), "unsupported config version") { t.Fatalf("stderr = %q, want parse error", stderr.String()) } } func TestRunConfigValidatePipelineOnlySuccessAndInvalidLane(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) t.Run("success", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "notes"}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } }) t.Run("invalid lane", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "missing"}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "selected artifact lane") { t.Fatalf("stderr = %q, want invalid lane error", stderr.String()) } }) } func TestRunConfigValidateOnlyWithoutPipelineFails(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "events"}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "--only requires --pipeline") { t.Fatalf("stderr = %q, want only/pipeline error", stderr.String()) } } func TestRunConfigValidateRejectsMalformedOnlyValues(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) tests := []string{",", "notes,", ",notes", "events, ,notes"} for _, only := range tests { t.Run(only, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", only}, &stdout, &stderr, Options{ Catalog: fakeCatalog(t), }) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), "--only") { t.Fatalf("stderr = %q, want --only error", stderr.String()) } }) } } func TestRunPipelinesListSortedTextOutput(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{ "zeta": {"events"}, "alpha": {"events"}, })) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := stdout.String(), "alpha\nzeta\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } } func TestRunPipelinesListStableJSONOutput(t *testing.T) { configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{ "b": {"events"}, "a": {"events"}, })) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, Options{}) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := stdout.String(), "{\"pipelines\":[\"a\",\"b\"]}\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } } func TestRunUsesNotariusConfigWhenConfigFlagAbsent(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, Options{ LookupEnv: mapLookup(map[string]string{"NOTARIUS_CONFIG": configPath}), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } if got, want := stdout.String(), "example\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } } func TestRunConfigValidateResolvesAPIKeyEnvThroughOptions(t *testing.T) { configPath := writeTestConfig(t, `version: 1 llm_profiles: default: api_key_env: NOTARIUS_TEST_API_KEY pipelines: example: input: fake/input artifacts: events: extract: fake/extract `) var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{ LookupEnv: mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"}), }) if code != 0 { t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) } } func TestRunMissingConfigPathProducesActionableError(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", filepath.Join(t.TempDir(), "missing.yml")}, &stdout, &stderr, Options{}) if code != 1 { t.Fatalf("RunWithOptions() code = %d, want 1", code) } if !strings.Contains(stderr.String(), "config file") || !strings.Contains(stderr.String(), "not available") { t.Fatalf("stderr = %q, want actionable missing config error", stderr.String()) } } func TestRunRejectsAdHocStructuralFlags(t *testing.T) { configPath := writeTestConfig(t, testConfigYAML("example", "events")) flags := []string{"--extractor", "--chunker", "--input", "--merge", "--normalize"} for _, flagName := range flags { t.Run(flagName, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"config", "validate", "--config", configPath, flagName, "value"}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "flag provided but not defined") { t.Fatalf("stderr = %q, want invalid flag error", stderr.String()) } }) } } func TestRunInvalidFlagsExitTwo(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := RunWithOptions([]string{"pipelines", "list", "--bogus"}, &stdout, &stderr, Options{}) if code != 2 { t.Fatalf("RunWithOptions() code = %d, want 2", code) } if !strings.Contains(stderr.String(), "flag provided but not defined") { t.Fatalf("stderr = %q, want invalid flag error", stderr.String()) } } func writeTestConfig(t *testing.T, content string) string { t.Helper() return writeFile(t, "config.yml", content) } func writeFile(t *testing.T, name string, content string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } return path } func testConfigYAML(pipelineID string, laneIDs ...string) string { return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs}) } func testConfigYAMLForPipelines(pipelines map[string][]string) string { var b strings.Builder b.WriteString("version: 1\n") b.WriteString("pipelines:\n") for pipelineID, laneIDs := range pipelines { b.WriteString(" " + pipelineID + ":\n") b.WriteString(" input: fake/input\n") b.WriteString(" artifacts:\n") for _, laneID := range laneIDs { b.WriteString(" " + laneID + ":\n") b.WriteString(" extract: fake/extract\n") } } return b.String() } func fakeCatalog(t *testing.T) pipeline.ModuleCatalog { t.Helper() inputs := pipeline.NewInputAdapterRegistry() chunkers := pipeline.NewChunkerRegistry() extractors := pipeline.NewExtractorRegistry() mergers := pipeline.NewMergerRegistry() normalizers := pipeline.NewNormalizerRegistry() validators := pipeline.NewValidatorRegistry() outputs := pipeline.NewOutputEncoderRegistry() mustRegisterInput(t, inputs, pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}}) mustRegisterChunker(t, chunkers, pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}}) mustRegisterExtractor(t, extractors, pipeline.ModuleSpec{Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}}) mustRegisterMerger(t, mergers, pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}}) mustRegisterNormalizer(t, normalizers, pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}}) mustRegisterOutput(t, outputs, pipeline.ModuleSpec{Key: "json", Stage: pipeline.StageOutput, Requires: []string{"normalized"}}) return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, Validators: validators, Outputs: outputs, } } func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil { t.Fatalf("register input: %v", err) } } func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil { t.Fatalf("register chunker: %v", err) } } func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Extractor, error) { return nil, nil }); err != nil { t.Fatalf("register extractor: %v", err) } } func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Merger, error) { return nil, nil }); err != nil { t.Fatalf("register merger: %v", err) } } func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.Normalizer, error) { return nil, nil }); err != nil { t.Fatalf("register normalizer: %v", err) } } func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil { t.Fatalf("register output: %v", err) } } func mapLookup(values map[string]string) func(string) (string, bool) { return func(key string) (string, bool) { value, ok := values[key] return value, ok } }