diff --git a/configuration_sources.go b/configuration_sources.go index 808412e..1b9ed86 100644 --- a/configuration_sources.go +++ b/configuration_sources.go @@ -8,9 +8,25 @@ import ( "path/filepath" "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit/internal/domain" "gitea.maximumdirect.net/eric/promptkit/internal/filecatalog" ) +// CollectionInput mounts source at the selected collection's configuration +// destination and enables ConfigKit imports for that input. The source is not +// read or validated until ConfigKit loads it. Callers may adjust the returned +// input, including its filesystem context or imports setting. +func CollectionInput(namespace string, source configkit.Source) (configkit.Input, error) { + if !domain.IsValidResourceNamespace(namespace) { + return configkit.Input{}, fmt.Errorf("%w: invalid collection namespace %q", ErrInvalidConfig, namespace) + } + return configkit.Input{ + Source: source, + Destination: configkit.Keys("collections", namespace), + Imports: true, + }, nil +} + // DirectoryInputs selects the YAML files beneath an explicitly chosen host // directory as one strict-composition group. The returned order is deterministic; // it does not assign precedence among those files. Discovery is recursive over diff --git a/configuration_sources_test.go b/configuration_sources_test.go index 824e6e3..030f542 100644 --- a/configuration_sources_test.go +++ b/configuration_sources_test.go @@ -1,6 +1,8 @@ package promptkit_test import ( + "context" + "errors" "io/fs" "os" "path/filepath" @@ -16,6 +18,78 @@ type sourceDocument struct { Second string `yaml:"second"` } +func TestCollectionInputValidatesWithoutReadingSource(t *testing.T) { + input, err := promptkit.CollectionInput("docs", configkit.File(filepath.Join(t.TempDir(), "not-read.yaml"))) + if err != nil { + t.Fatal(err) + } + if input.Destination.String() != "/collections/docs" || !input.Imports { + t.Fatalf("collection input = %+v, want destination /collections/docs with imports enabled", input) + } + for _, namespace := range []string{"", " ", "docs/api"} { + if _, err := promptkit.CollectionInput(namespace, configkit.NoSource()); !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Errorf("namespace %q error = %v, want ErrInvalidConfig", namespace, err) + } + } +} + +func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) { + directory := t.TempDir() + mainPath := filepath.Join(directory, "collection.yaml") + defaultsPath := filepath.Join(directory, "defaults.yaml") + if err := os.WriteFile(mainPath, []byte("configkit:\n imports:\n - path: defaults.yaml\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(defaultsPath, []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil { + t.Fatal(err) + } + input, err := promptkit.CollectionInput("docs", configkit.File(mainPath)) + if err != nil { + t.Fatal(err) + } + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}, + Main: &configkit.Input{ + Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}), + Destination: configkit.Keys("collections", "docs"), + }, + }}) + if err != nil { + t.Fatalf("construct engine with native collection import: %v", err) + } + inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting") + if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" { + t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err) + } +} + +func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) { + files := fstest.MapFS{ + "collection.yaml": {Data: []byte("configkit:\n imports:\n - path: defaults.yaml\n")}, + "defaults.yaml": {Data: []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")}, + } + filesystem := configkit.NewFilesystem("embedded collection", files) + input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml")) + if err != nil { + t.Fatal(err) + } + input.Filesystem = &configkit.FilesystemContext{FS: &filesystem, Root: "."} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}, + Main: &configkit.Input{ + Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}), + Destination: configkit.Keys("collections", "docs"), + }, + }}) + if err != nil { + t.Fatalf("construct engine with embedded collection import: %v", err) + } + inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting") + if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" { + t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err) + } +} + func TestDirectoryInputsSelectsSortedFilesWithRootContext(t *testing.T) { root := t.TempDir() if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f6620bf..4a4e740 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -593,6 +593,13 @@ Validation: source-helper contract tests and focused configuration/root tests. Verify helper construction itself performs no source read using an existing controlled source seam where needed, rather than adding a production interface. +Completion: added `CollectionInput` with shared namespace validation, a +`collections/` destination, and imports enabled without reading the +source. Contract tests cover invalid namespaces and deferred source loading; +native-file and embedded-filesystem imports assemble successfully, preserve +their source context, and accept a later override. Validation passed: +`GOWORK=off go test ./...`, `GOWORK=off go vet .`, and `git diff --check`. + ## Stage 12: Add Immutable Scopes To Ordinary Execution Scope: public `Namespaces`/`WithNamespaces` and shared engine-boundary routing.