package config import ( "os" "path/filepath" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics" ) func TestParseMinimalValidConfig(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 2 `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } if fileCfg.Version != SupportedFileConfigVersion { t.Fatalf("unexpected version: %d", fileCfg.Version) } } func TestLoadFileConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(path, []byte("version: 2\n"), 0o644); err != nil { t.Fatalf("write config: %v", err) } fileCfg, err := LoadFileConfig(path) if err != nil { t.Fatalf("LoadFileConfig: %v", err) } if fileCfg.Version != SupportedFileConfigVersion { t.Fatalf("unexpected version: %d", fileCfg.Version) } } func TestParseFileConfigRejectsUnknownYAMLFields(t *testing.T) { _, err := ParseFileConfigYAML([]byte(` version: 2 unexpected: true `)) if err == nil || !strings.Contains(err.Error(), "field unexpected not found") { t.Fatalf("expected unknown field error, got %v", err) } } func TestParseFileConfigRejectsUnknownModuleBindingFields(t *testing.T) { _, err := ParseFileConfigYAML([]byte(` version: 2 pipelines: example: input: module: fake/input unexpected: true artifacts: events: extract: fake/extract `)) if err == nil || !strings.Contains(err.Error(), "field unexpected not found") { t.Fatalf("expected unknown binding field error, got %v", err) } } func TestParseFileConfigRejectsMissingAndUnsupportedVersion(t *testing.T) { tests := []struct { name string data string want string }{ {name: "missing", data: `scriptorium: {}`, want: "version is required"}, {name: "unsupported", data: `version: 1`, want: "unsupported config version"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := ParseFileConfigYAML([]byte(tc.data)) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("expected error containing %q, got %v", tc.want, err) } }) } } func TestParseFileConfigRejectsStaleLLMProfiles(t *testing.T) { _, err := ParseFileConfigYAML([]byte(` version: 2 llm_profiles: default: {} `)) if err == nil || !strings.Contains(err.Error(), "llm_profiles") { t.Fatalf("expected stale llm_profiles error, got %v", err) } } func TestParseFileConfigScriptoriumProfileSources(t *testing.T) { t.Run("profile dir", func(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 scriptorium: profile_dir: ./profiles `) if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" { t.Fatalf("Scriptorium = %+v, want profile_dir", cfg.Scriptorium) } }) t.Run("profile file", func(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 scriptorium: profile_file: ./profiles.yml `) if cfg.Scriptorium.ProfileFile != "./profiles.yml" || cfg.Scriptorium.ProfileDir != "" { t.Fatalf("Scriptorium = %+v, want profile_file", cfg.Scriptorium) } }) } func TestParseFileConfigModuleBindingForms(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 pipelines: example: input: fake/input chunk: module: generic retries: 2 options: size: 10 flags: - alpha nested: enabled: true artifacts: events: extract: module: fake/extract llm_profile: fast retries: 3 options: temperature: 0 merge: module: appendorder retries: 1 normalize: module: noop output: json `) profile := cfg.Pipelines["example"] if profile.Input.Module != "fake/input" { t.Fatalf("unexpected input binding: %+v", profile.Input) } if profile.Chunk.Module != "generic" { t.Fatalf("unexpected chunk binding: %+v", profile.Chunk) } if profile.Chunk.Retries != 2 { t.Fatalf("chunk retries = %d, want 2", profile.Chunk.Retries) } if profile.Chunk.Options["size"] != 10 { t.Fatalf("expected chunk options to preserve scalar, got %#v", profile.Chunk.Options) } if !reflect.DeepEqual(profile.Chunk.Options["flags"], []any{"alpha"}) { t.Fatalf("expected list option, got %#v", profile.Chunk.Options["flags"]) } nested, ok := profile.Chunk.Options["nested"].(map[string]any) if !ok || nested["enabled"] != true { t.Fatalf("expected nested map option, got %#v", profile.Chunk.Options["nested"]) } lane := profile.Artifacts["events"] if lane.Extract.Module != "fake/extract" || lane.Extract.LLMProfile != "fast" { t.Fatalf("unexpected extract binding: %+v", lane.Extract) } if lane.Extract.Retries != 3 || lane.Merge.Retries != 1 { t.Fatalf("unexpected retries: extract=%d merge=%d", lane.Extract.Retries, lane.Merge.Retries) } if lane.Extract.Options["temperature"] != 0 { t.Fatalf("expected object options, got %#v", lane.Extract.Options) } if lane.Merge.Module != "appendorder" || lane.Normalize.Module != "noop" { t.Fatalf("unexpected lane defaults: %+v", lane) } if profile.Output.Module != "json" { t.Fatalf("unexpected output binding: %+v", profile.Output) } } func TestParseFileConfigReferenceMaps(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 pipelines: example: input: fake/input references: " roster ": " ./shared-roster.yml " artifacts: events: extract: fake/extract references: " lore ": " ./lore.md " `) profile := cfg.Pipelines["example"] if !reflect.DeepEqual(profile.References, map[string]string{"roster": "./shared-roster.yml"}) { t.Fatalf("pipeline references = %#v, want trimmed map", profile.References) } gotLaneRefs := profile.Artifacts["events"].References if !reflect.DeepEqual(gotLaneRefs, map[string]string{"lore": "./lore.md"}) { t.Fatalf("lane references = %#v, want trimmed map", gotLaneRefs) } } func TestParseFileConfigStageLocalReferenceMaps(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 pipelines: example: input: fake/input chunk: module: generic references: " scene_guide ": " ./scenes.md " artifacts: events: extract: module: fake/extract references: " glossary ": " ./glossary.md " " roster ": " ./extract-roster.yml " references: roster: ./legacy-roster.yml lore: ./lore.md merge: module: appendorder references: " merge_notes ": " ./merge.md " normalize: module: noop references: " normalization_notes ": " ./normalization.md " `) profile := cfg.Pipelines["example"] if !reflect.DeepEqual(profile.Chunk.References, map[string]string{"scene_guide": "./scenes.md"}) { t.Fatalf("chunk references = %#v, want trimmed map", profile.Chunk.References) } lane := profile.Artifacts["events"] if !reflect.DeepEqual(lane.References, map[string]string{"lore": "./lore.md", "roster": "./legacy-roster.yml"}) { t.Fatalf("lane references = %#v, want trimmed map", lane.References) } wantExtract := map[string]string{ "glossary": "./glossary.md", "lore": "./lore.md", "roster": "./extract-roster.yml", } if !reflect.DeepEqual(lane.Extract.References, wantExtract) { t.Fatalf("extract references = %#v, want legacy merged with extract override %#v", lane.Extract.References, wantExtract) } if !reflect.DeepEqual(lane.Merge.References, map[string]string{"merge_notes": "./merge.md"}) { t.Fatalf("merge references = %#v, want trimmed map", lane.Merge.References) } if !reflect.DeepEqual(lane.Normalize.References, map[string]string{"normalization_notes": "./normalization.md"}) { t.Fatalf("normalize references = %#v, want trimmed map", lane.Normalize.References) } } func TestParseFileConfigValidatorMixedBindingForms(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 pipelines: example: input: fake/input artifacts: events: extract: fake/extract validators: - fake/validator - module: fake/llm-validator llm_profile: careful options: threshold: 0.7 `) validators := cfg.Pipelines["example"].Artifacts["events"].Validators if len(validators) != 2 { t.Fatalf("expected two validators, got %d", len(validators)) } if validators[0].Module != "fake/validator" { t.Fatalf("unexpected shorthand validator: %+v", validators[0]) } if validators[1].Module != "fake/llm-validator" || validators[1].LLMProfile != "careful" { t.Fatalf("unexpected object validator: %+v", validators[1]) } if validators[1].Options["threshold"] != 0.7 { t.Fatalf("unexpected validator options: %#v", validators[1].Options) } } func TestParseFileConfigStageLocalValidatorOverrides(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 pipelines: example: input: fake/input chunk: module: generic validators: [] artifacts: events: extract: module: fake/extract validators: - fake/validator - module: fake/llm-validator llm_profile: careful options: threshold: 0.7 merge: module: appendorder validators: [] normalize: module: noop `) profile := cfg.Pipelines["example"] if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 { t.Fatalf("chunk validator override = %#v, want explicit empty", profile.Chunk.Validators) } lane := profile.Artifacts["events"] if !lane.Extract.Validators.Set { t.Fatalf("extract validator override Set = false, want true") } validators := lane.Extract.Validators.Validators if len(validators) != 2 { t.Fatalf("extract validators = %#v, want two validators", validators) } if validators[0].Module != "fake/validator" { t.Fatalf("first validator = %#v, want fake/validator", validators[0]) } if validators[1].Module != "fake/llm-validator" || validators[1].LLMProfile != "careful" { t.Fatalf("second validator = %#v, want LLM validator with profile", validators[1]) } if validators[1].Options["threshold"] != 0.7 { t.Fatalf("second validator options = %#v, want threshold", validators[1].Options) } if !lane.Merge.Validators.Set || len(lane.Merge.Validators.Validators) != 0 { t.Fatalf("merge validator override = %#v, want explicit empty", lane.Merge.Validators) } if lane.Normalize.Validators.Set { t.Fatalf("normalize validator override Set = true, want omitted") } } func TestApplyFileConfigRejectsDuplicateTrimmedPipelineIDs(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 2 pipelines: example: input: fake/input " example ": input: fake/other-input `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), "pipeline id") || !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate pipeline ID error, got %v", err) } } func TestApplyFileConfigRejectsDuplicateTrimmedArtifactLaneIDs(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 2 pipelines: example: input: fake/input artifacts: events: extract: fake/extract " events ": extract: fake/other-extract `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), `pipeline "example" artifact lane id`) || !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate artifact lane ID error, got %v", err) } } func TestApplyFileConfigRejectsDuplicateTrimmedReferenceSlots(t *testing.T) { tests := []struct { name string raw string want string }{ { name: "pipeline", raw: ` version: 2 pipelines: example: input: fake/input references: roster: ./first.yml " roster ": ./second.yml `, want: `pipeline "example" reference slot`, }, { name: "lane", raw: ` version: 2 pipelines: example: input: fake/input artifacts: events: extract: fake/extract references: roster: ./first.yml " roster ": ./second.yml `, want: `pipeline "example" lane "events" reference slot`, }, { name: "chunk", raw: ` version: 2 pipelines: example: input: fake/input chunk: module: generic references: roster: ./first.yml " roster ": ./second.yml `, want: `pipeline "example" chunk reference slot`, }, { name: "extract", raw: ` version: 2 pipelines: example: input: fake/input artifacts: events: extract: module: fake/extract references: roster: ./first.yml " roster ": ./second.yml `, want: `pipeline "example" lane "events" extract reference slot`, }, { name: "normalize", raw: ` version: 2 pipelines: example: input: fake/input artifacts: events: extract: fake/extract normalize: module: noop references: roster: ./first.yml " roster ": ./second.yml `, want: `pipeline "example" lane "events" normalize reference slot`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(tc.raw)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate reference slot error, got %v", err) } }) } } func TestApplyFileConfigRejectsInvalidScriptoriumSources(t *testing.T) { tests := []struct { name string raw string want string }{ {name: "empty profile dir", raw: "profile_dir: ' '", want: "profile_dir"}, {name: "empty profile file", raw: "profile_file: ' '", want: "profile_file"}, {name: "both sources", raw: "profile_dir: ./profiles\n profile_file: ./profiles.yml", want: "mutually exclusive"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cfg := Default() fileCfg, err := ParseFileConfigYAML([]byte(` version: 2 scriptorium: ` + tc.raw + ` `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil { err = cfg.Validate() } if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("expected error containing %q, got %v", tc.want, err) } }) } } func TestApplyFileConfigOperationalSections(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 concurrency: total_llm: 4 diagnostics: work_dir: /tmp/notarius-test retention: always `) if cfg.Concurrency.TotalLLM != 4 { t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM) } if got := cfg.Concurrency.StageWorkers["extract"]; got != 4 { t.Fatalf("default extract workers = %d, want total concurrency", got) } if cfg.Diagnostics.WorkDir != "/tmp/notarius-test" { t.Fatalf("unexpected work dir: %q", cfg.Diagnostics.WorkDir) } if cfg.Diagnostics.Retention != diagnostics.RetentionAlways { t.Fatalf("unexpected retention: %q", cfg.Diagnostics.Retention) } } func TestApplyFileConfigStageWorkers(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 concurrency: total_llm: 4 stage_workers: extract: 3 `) if got := cfg.Concurrency.StageWorkers["extract"]; got != 3 { t.Fatalf("extract workers = %d, want 3", got) } if err := cfg.Validate(); err != nil { t.Fatalf("Validate() error = %v, want nil", err) } } func TestApplyFileConfigEmptyStageWorkersDefaultsExtractToTotal(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 concurrency: total_llm: 4 stage_workers: {} `) if got := cfg.Concurrency.StageWorkers["extract"]; got != 4 { t.Fatalf("extract workers = %d, want total concurrency 4", got) } } func TestApplyFileConfigRejectsUnsupportedStageWorkerKeys(t *testing.T) { for _, test := range []struct { name string key string want string }{ {name: "empty", key: "' '", want: "must not be empty"}, {name: "unknown", key: "merge", want: "not supported"}, } { t.Run(test.name, func(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte("version: 2\nconcurrency:\n stage_workers:\n " + test.key + ": 1\n")) if err != nil { t.Fatalf("ParseFileConfigYAML() error = %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("ApplyFileConfig() error = %v, want %q", err, test.want) } }) } } func TestApplyFileConfigWorkspaceSection(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 workspace: directory: /var/lib/notarius diagnostics: enabled: false retention: never resume: enabled: true debug: enabled: true diagnostics: work_dir: /tmp/legacy retention: always `) if cfg.Workspace.Directory != "/var/lib/notarius" { t.Fatalf("workspace directory = %q, want /var/lib/notarius", cfg.Workspace.Directory) } if cfg.DiagnosticsEnabled() { t.Fatalf("expected diagnostics disabled") } if cfg.Diagnostics.WorkDir != "/var/lib/notarius/diagnostics" { t.Fatalf("effective diagnostics work dir = %q, want workspace diagnostics root", cfg.Diagnostics.WorkDir) } if cfg.Diagnostics.Retention != diagnostics.RetentionNever { t.Fatalf("effective diagnostics retention = %q, want workspace override", cfg.Diagnostics.Retention) } if !cfg.Workspace.Resume.Enabled { t.Fatalf("expected resume enabled") } if !cfg.Workspace.Debug.Enabled { t.Fatalf("expected debug enabled") } } func TestApplyFileConfigLegacyDiagnosticsRemainCompatible(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 diagnostics: work_dir: /tmp/legacy retention: always `) if cfg.Workspace.Directory != "" { t.Fatalf("workspace directory = %q, want unset", cfg.Workspace.Directory) } if !cfg.DiagnosticsEnabled() { t.Fatalf("expected diagnostics enabled") } if cfg.Diagnostics.WorkDir != "/tmp/legacy" { t.Fatalf("effective diagnostics work dir = %q, want legacy", cfg.Diagnostics.WorkDir) } if cfg.Diagnostics.Retention != diagnostics.RetentionAlways { t.Fatalf("effective diagnostics retention = %q, want legacy", cfg.Diagnostics.Retention) } } func TestApplyFileConfigWorkspaceRetentionOverridesLegacyRetentionOnlyWhenSet(t *testing.T) { t.Run("legacy retained", func(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 workspace: directory: /var/lib/notarius diagnostics: retention: never `) if cfg.Diagnostics.Retention != diagnostics.RetentionNever { t.Fatalf("effective diagnostics retention = %q, want legacy", cfg.Diagnostics.Retention) } }) t.Run("workspace overrides", func(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 2 workspace: directory: /var/lib/notarius diagnostics: retention: always diagnostics: retention: never `) if cfg.Diagnostics.Retention != diagnostics.RetentionAlways { t.Fatalf("effective diagnostics retention = %q, want workspace", cfg.Diagnostics.Retention) } }) } func parseAndApplyConfig(t *testing.T, raw string) Config { t.Helper() fileCfg, err := ParseFileConfigYAML([]byte(raw)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil { t.Fatalf("ApplyFileConfig: %v", err) } return cfg } func emptyLookup(string) (string, bool) { return "", false } func mapLookup(values map[string]string) func(string) (string, bool) { return func(key string) (string, bool) { value, ok := values[key] return value, ok } }