diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 70dc774..373528b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -82,7 +82,11 @@ provider discovery, new caller limits, or stronger catalog availability promises ## Stage 1 — Capture And Read Identity Inventories -**Status: Pending.** +**Status: Complete (2026-10-09).** Verified with +`go test ./internal/configuration ./internal/domain` and +`go vet ./internal/configuration ./internal/domain`. Adapter tests cover +effective sorted identities, literal keys, partial profiles, empty loads, +caller-owned results, and canceled contexts. **Scope:** `internal/domain`, `internal/configuration`, and focused adapter tests. diff --git a/internal/configuration/listing.go b/internal/configuration/listing.go new file mode 100644 index 0000000..32776d1 --- /dev/null +++ b/internal/configuration/listing.go @@ -0,0 +1,29 @@ +package configuration + +import ( + "context" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +func (state *loaded) ListProfiles(ctx context.Context) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + profiles := append([]string(nil), state.profileIDs...) + if err := ctx.Err(); err != nil { + return nil, err + } + return profiles, nil +} + +func (state *loaded) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + prompts := append([]domain.PromptIdentity(nil), state.promptIDs...) + if err := ctx.Err(); err != nil { + return nil, err + } + return prompts, nil +} diff --git a/internal/configuration/listing_test.go b/internal/configuration/listing_test.go new file mode 100644 index 0000000..dbd07af --- /dev/null +++ b/internal/configuration/listing_test.go @@ -0,0 +1,142 @@ +package configuration + +import ( + "context" + "errors" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +func TestLoadedListsSortedEffectiveIdentities(t *testing.T) { + state, err := Load(mapping("identities", map[string]any{ + "backends": map[string]any{ + "local": map[string]any{"endpoint": "http://localhost:8080/v1"}, + }, + "profiles": map[string]any{ + "z/profile": map[string]any{"backend": "local", "model": "z-model"}, + "partial/profile": map[string]any{"backend": "local"}, + "a/profile": map[string]any{"backend": "local", "model": "a-model"}, + " z/profile ": map[string]any{"backend": "local", "model": "spaced-model"}, + }, + "prompts": map[string]any{ + "z/prompt": map[string]any{"v1": listingPromptDefinition()}, + "a/prompt": map[string]any{"v1": listingPromptDefinition()}, + " z/prompt ": map[string]any{ + "2": listingPromptDefinition(), + "10": listingPromptDefinition(), + }, + }, + }), nil) + if err != nil { + t.Fatal(err) + } + + profiles, err := state.ListProfiles(context.Background()) + if err != nil { + t.Fatal(err) + } + wantProfiles := []string{" z/profile ", "a/profile", "partial/profile", "z/profile"} + if !reflect.DeepEqual(profiles, wantProfiles) { + t.Fatalf("profile IDs = %#v, want %#v", profiles, wantProfiles) + } + + prompts, err := state.ListPrompts(context.Background()) + if err != nil { + t.Fatal(err) + } + wantPrompts := []domain.PromptIdentity{ + {ID: " z/prompt ", Version: "10"}, + {ID: " z/prompt ", Version: "2"}, + {ID: "a/prompt", Version: "v1"}, + {ID: "z/prompt", Version: "v1"}, + } + if !reflect.DeepEqual(prompts, wantPrompts) { + t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts) + } +} + +func TestLoadedListsAreIndependent(t *testing.T) { + state, err := Load(mapping("identities", map[string]any{ + "profiles": map[string]any{"profile": map[string]any{}}, + "prompts": map[string]any{"example": map[string]any{"v1": listingPromptDefinition()}}, + }), nil) + if err != nil { + t.Fatal(err) + } + + profiles, err := state.ListProfiles(context.Background()) + if err != nil { + t.Fatal(err) + } + profiles = append(profiles, "caller profile") + profiles[0] = "changed profile" + + prompts, err := state.ListPrompts(context.Background()) + if err != nil { + t.Fatal(err) + } + prompts[0].ID = "changed prompt" + prompts[0].Version = "changed version" + + profiles, err = state.ListProfiles(context.Background()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(profiles, []string{"profile"}) { + t.Fatalf("caller mutation changed profile inventory: %#v", profiles) + } + prompts, err = state.ListPrompts(context.Background()) + if err != nil { + t.Fatal(err) + } + wantPrompts := []domain.PromptIdentity{{ID: "example", Version: "v1"}} + if !reflect.DeepEqual(prompts, wantPrompts) { + t.Fatalf("caller mutation changed prompt inventory: %#v", prompts) + } +} + +func TestLoadedListsEmptyInventory(t *testing.T) { + state, err := Load(configkit.Input{Source: configkit.NoSource()}, nil) + if err != nil { + t.Fatal(err) + } + profiles, err := state.ListProfiles(context.Background()) + if err != nil || len(profiles) != 0 { + t.Fatalf("empty profile listing = %#v, %v", profiles, err) + } + prompts, err := state.ListPrompts(context.Background()) + if err != nil || len(prompts) != 0 { + t.Fatalf("empty prompt listing = %#v, %v", prompts, err) + } +} + +func TestLoadedListsHonorCanceledContext(t *testing.T) { + state, err := Load(mapping("identities", map[string]any{ + "profiles": map[string]any{"partial": map[string]any{}}, + "prompts": map[string]any{"example": map[string]any{"v1": listingPromptDefinition()}}, + }), nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + profiles, err := state.ListProfiles(ctx) + if !errors.Is(err, context.Canceled) || len(profiles) != 0 { + t.Fatalf("canceled profile listing = %#v, %v", profiles, err) + } + prompts, err := state.ListPrompts(ctx) + if !errors.Is(err, context.Canceled) || len(prompts) != 0 { + t.Fatalf("canceled prompt listing = %#v, %v", prompts, err) + } +} + +func listingPromptDefinition() map[string]any { + return map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "Hello."}}, + "output": map[string]any{"format": "text", "validation_mode": "basic"}, + } +} diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 8b08da2..e9c9bf0 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -98,6 +98,8 @@ type loaded struct { ambiguous map[string]bool registry *backend.Registry indexedProfiles map[string]domain.ExecutionProfile + profileIDs []string + promptIDs []domain.PromptIdentity } var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) @@ -143,6 +145,12 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) if err := state.buildExecutionProfiles(); err != nil { return nil, fmt.Errorf("load configuration: %w", err) } + state.profileIDs = sortedKeys(state.indexedProfiles) + for _, id := range sortedKeys(state.prompts) { + for _, version := range sortedKeys(state.prompts[id]) { + state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id, Version: version}) + } + } return state, nil } diff --git a/internal/domain/domain.go b/internal/domain/domain.go index c13e885..78d2c25 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -135,6 +135,12 @@ type Artifact struct { Hash string } +// PromptIdentity identifies one configured prompt version. +type PromptIdentity struct { + ID string + Version string +} + // PromptDefinition represents a configured prompt execution definition. type PromptDefinition struct { ID string `yaml:"id"`