Correct prompt definition selection and decoding

This commit is contained in:
2026-08-11 22:11:39 +00:00
parent a718762da1
commit 25f1ba0b30
4 changed files with 435 additions and 129 deletions

View File

@@ -9,9 +9,10 @@ explains how to select these sources and invoke the engine. The
owns the resulting outbound wire behavior. owns the resulting outbound wire behavior.
Prompt and profile sources recursively discover files ending in `.yaml` or Prompt and profile sources recursively discover files ending in `.yaml` or
`.yml`. YAML decoding is strict: unknown fields are errors for the selected `.yml`. Each prompt-definition file contains exactly one YAML document;
definition. Definitions are selected by their YAML `id`, not their file name comments and trailing whitespace are allowed. YAML decoding is strict: unknown
or directory. fields are errors for the selected definition. Definitions are selected by
their YAML `id`, not their file name or directory.
## Prompt Definitions ## Prompt Definitions

View File

@@ -12,9 +12,12 @@ validation modes, built-in catalog, and source precedence.
## Prompt Definitions ## Prompt Definitions
`internal/promptdef` discovers YAML deterministically, decodes and validates `internal/promptdef` discovers YAML deterministically, requires one strictly
definitions, selects an ID and optional version, and resolves file-backed decoded document per file, and scans normalized ID and version metadata before
message content through an explicit source-root abstraction. Operating-system validating the selected definition. It retains the point-in-time metadata scan
needed for duplicate detection while opening file-backed message content only
for one exact selected candidate. Content resolution uses an explicit
source-root abstraction. Operating-system
sources enforce containment against canonical roots and targets so symlinks sources enforce containment against canonical roots and targets so symlinks
cannot escape. Injected `fs.FS` sources enforce containment in their clean cannot escape. Injected `fs.FS` sources enforce containment in their clean
relative path namespace. A single-file source uses the selected prompt file's relative path namespace. A single-file source uses the selected prompt file's

View File

@@ -5,10 +5,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"os" "os"
"path"
"path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/promptkit/internal/domain" "gitea.maximumdirect.net/eric/promptkit/internal/domain"
@@ -116,33 +115,24 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin
} }
relPath := filecatalog.RelativePath(r.dir, fullPath) relPath := filecatalog.RelativePath(r.dir, fullPath)
fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id data, err := os.ReadFile(fullPath)
raw, err := loadPromptDefinitionFile(fullPath)
if err != nil { if err != nil {
if fileMatch || promptDefinitionFileHasID(fullPath, id) { continue
}
raw, err := decodePromptDefinition(data)
if err != nil {
if promptDefinitionDataMatches(data, id, version) {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err) return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
} }
continue continue
} }
if !promptDefinitionMatches(raw, id, version) {
def, err := normalizePromptDefinition(raw, r.sourceRoot, fullPath)
if err != nil {
if fileMatch || strings.TrimSpace(raw.ID) == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
}
continue
}
if def.ID != id {
continue
}
if version != "" && def.Version != version {
continue continue
} }
matches = append(matches, promptDefinitionMatch{ matches = append(matches, promptDefinitionMatch{
def: def, raw: raw,
path: relPath, sourcePath: fullPath,
path: relPath,
}) })
} }
@@ -158,7 +148,12 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin
} }
if len(matches) == 1 { if len(matches) == 1 {
return matches[0].def, nil match := matches[0]
def, err := normalizePromptDefinition(match.raw, r.sourceRoot, match.sourcePath)
if err != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, match.path, err)
}
return def, nil
} }
return nil, ErrPromptDefinitionNotFound return nil, ErrPromptDefinitionNotFound
@@ -169,37 +164,9 @@ func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, versi
} }
type promptDefinitionMatch struct { type promptDefinitionMatch struct {
def *domain.PromptDefinition raw *promptDefinitionFile
path string sourcePath string
} path string
func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read prompt definition file: %w", err)
}
var raw promptDefinitionFile
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&raw); err != nil {
return nil, err
}
return &raw, nil
}
func promptDefinitionFileHasID(path string, id string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
var raw struct {
ID string `yaml:"id"`
}
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
return false
}
return strings.TrimSpace(raw.ID) == id
} }
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, sourceRoot contentSourceRoot, id string, version string) (*domain.PromptDefinition, error) { func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, sourceRoot contentSourceRoot, id string, version string) (*domain.PromptDefinition, error) {
@@ -223,40 +190,25 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, sourceRo
} }
relPath := filecatalog.DisplayPath(root, fullPath) relPath := filecatalog.DisplayPath(root, fullPath)
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
data, err := fs.ReadFile(fsys, fullPath) data, err := fs.ReadFile(fsys, fullPath)
if err != nil { if err != nil {
if fileMatch {
return nil, fmt.Errorf("%w: %s: failed to read prompt definition file: %v", ErrInvalidYAML, relPath, err)
}
continue continue
} }
raw, err := decodePromptDefinition(data) raw, err := decodePromptDefinition(data)
if err != nil { if err != nil {
if fileMatch || promptDefinitionDataHasID(data, id) { if promptDefinitionDataMatches(data, id, version) {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err) return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
} }
continue continue
} }
if !promptDefinitionMatches(raw, id, version) {
def, err := normalizePromptDefinition(raw, sourceRoot, fullPath)
if err != nil {
if fileMatch || strings.TrimSpace(raw.ID) == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
}
continue
}
if def.ID != id {
continue
}
if version != "" && def.Version != version {
continue continue
} }
matches = append(matches, promptDefinitionMatch{ matches = append(matches, promptDefinitionMatch{
def: def, raw: raw,
path: relPath, sourcePath: fullPath,
path: relPath,
}) })
} }
@@ -272,7 +224,12 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, sourceRo
} }
if len(matches) == 1 { if len(matches) == 1 {
return matches[0].def, nil match := matches[0]
def, err := normalizePromptDefinition(match.raw, sourceRoot, match.sourcePath)
if err != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, match.path, err)
}
return def, nil
} }
return nil, ErrPromptDefinitionNotFound return nil, ErrPromptDefinitionNotFound
@@ -285,17 +242,39 @@ func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
if err := decoder.Decode(&raw); err != nil { if err := decoder.Decode(&raw); err != nil {
return nil, err return nil, err
} }
var additional yaml.Node
if err := decoder.Decode(&additional); err != io.EOF {
if err != nil {
return nil, err
}
return nil, errors.New("prompt definition file must contain exactly one YAML document")
}
return &raw, nil return &raw, nil
} }
func promptDefinitionDataHasID(data []byte, id string) bool { func promptDefinitionDataMatches(data []byte, id string, version string) bool {
var raw struct { var raw struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Version string `yaml:"version"`
} }
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil { if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
return false return false
} }
return strings.TrimSpace(raw.ID) == id return promptSelectorMatches(raw.ID, raw.Version, id, version)
}
func promptDefinitionMatches(raw *promptDefinitionFile, id string, version string) bool {
if raw == nil {
return false
}
return promptSelectorMatches(raw.ID, raw.Version, id, version)
}
func promptSelectorMatches(rawID string, rawVersion string, id string, version string) bool {
if strings.TrimSpace(rawID) != id {
return false
}
return version == "" || strings.TrimSpace(rawVersion) == version
} }
func normalizePromptDefinition(raw *promptDefinitionFile, sourceRoot contentSourceRoot, sourcePath string) (*domain.PromptDefinition, error) { func normalizePromptDefinition(raw *promptDefinitionFile, sourceRoot contentSourceRoot, sourcePath string) (*domain.PromptDefinition, error) {

View File

@@ -288,20 +288,20 @@ output:
targetErr error targetErr error
errSubstrs []string errSubstrs []string
}{ }{
{name: "invalid YAML", id: "invalid_yaml", targetErr: ErrInvalidYAML}, {name: "unidentifiable invalid YAML is unrelated", id: "invalid-yaml", targetErr: ErrPromptDefinitionNotFound},
{name: "missing id", id: "missing_id", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"id is required"}}, {name: "missing id is not selected by filename", id: "missing_id", targetErr: ErrPromptDefinitionNotFound},
{name: "no messages", id: "no_messages", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"at least one message is required"}}, {name: "no messages", id: "no-messages", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"at least one message is required"}},
{name: "both content and content_file", id: "both_content_and_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}}, {name: "both content and content_file", id: "both-content-and-content-file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
{name: "neither content nor content_file", id: "neither_content_nor_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}}, {name: "neither content nor content_file", id: "neither-content-nor-content-file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
{name: "missing content_file", id: "missing_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"failed to read content_file"}}, {name: "missing content_file", id: "missing-content-file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"failed to read content_file"}},
{name: "duplicate input names", id: "duplicate_input_names", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"duplicate input name"}}, {name: "duplicate input names", id: "duplicate-input-names", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"duplicate input name"}},
{name: "invalid validation mode", id: "invalid_validation_mode", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"invalid validation mode"}}, {name: "invalid validation mode", id: "invalid-validation-mode", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"invalid validation mode"}},
{name: "json_schema without schema_path", id: "json_schema_without_schema_path", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"schema_path"}}, {name: "json_schema without schema_path", id: "json-schema-without-schema-path", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"schema_path"}},
{name: "unknown input field", id: "unknown_input_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unknown_input_setting not found"}}, {name: "unknown input field", id: "unknown-input-field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unknown_input_setting not found"}},
{name: "empty cache control type", id: "empty_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "type is required"}}, {name: "empty cache control type", id: "empty-cache-control-type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "type is required"}},
{name: "unsupported cache control type", id: "unsupported_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported type"}}, {name: "unsupported cache control type", id: "unsupported-cache-control-type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported type"}},
{name: "unsupported cache control ttl", id: "unsupported_cache_control_ttl", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported ttl"}}, {name: "unsupported cache control ttl", id: "unsupported-cache-control-ttl", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported ttl"}},
{name: "unknown cache control field", id: "unknown_cache_control_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unexpected not found"}}, {name: "unknown cache control field", id: "unknown-cache-control-field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unexpected not found"}},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -441,14 +441,19 @@ func (f *recordingFS) Open(name string) (fs.File, error) {
} }
func (f *recordingFS) wasOpened(name string) bool { func (f *recordingFS) wasOpened(name string) bool {
return f.openCount(name) > 0
}
func (f *recordingFS) openCount(name string) int {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
count := 0
for _, opened := range f.opened { for _, opened := range f.opened {
if opened == name { if opened == name {
return true count++
} }
} }
return false return count
} }
func TestFSRepositoryRejectsDuplicatePromptIDs(t *testing.T) { func TestFSRepositoryRejectsDuplicatePromptIDs(t *testing.T) {
@@ -508,58 +513,340 @@ output:
} }
} }
func TestPromptRepositoriesApplyOutputContractRules(t *testing.T) { func TestPromptRepositorySelectionUsesYAMLMetadata(t *testing.T) {
const validDefinition = `
id: selected-prompt
version: "1"
messages:
- role: user
content: selected
output:
format: text
validation_mode: none
`
tests := []struct {
name string
files map[string]string
wantErr error
diagnostics []string
}{
{
name: "same-stem strict error with different YAML ID is unrelated",
files: map[string]string{
"selected-prompt.yaml": `
id: another-prompt
version: "1"
unknown: true
`,
"valid.yaml": validDefinition,
},
},
{
name: "unidentifiable same-stem YAML is unrelated",
files: map[string]string{
"selected-prompt.yaml": "id: [",
"valid.yaml": validDefinition,
},
},
{
name: "same ID invalid different version is unrelated",
files: map[string]string{
"invalid-version.yaml": `
id: selected-prompt
version: "2"
output:
format: text
validation_mode: none
`,
"valid.yaml": validDefinition,
},
},
{
name: "selected strict error is authoritative",
files: map[string]string{
"selected-strict.yaml": `
id: selected-prompt
version: "1"
unknown: true
`,
},
wantErr: ErrInvalidYAML,
diagnostics: []string{"selected-strict.yaml"},
},
{
name: "selected semantic error is authoritative",
files: map[string]string{
"selected-invalid.yaml": `
id: selected-prompt
version: "1"
output:
format: text
validation_mode: none
`,
},
wantErr: ErrInvalidPromptDefinition,
diagnostics: []string{"selected-invalid.yaml", "at least one message"},
},
}
for _, source := range promptRepositorySources() {
for _, tc := range tests {
t.Run(source.name+"/"+tc.name, func(t *testing.T) {
repo := source.newRepository(t, tc.files)
got, err := repo.GetPromptDefinition(context.Background(), "selected-prompt", "1")
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) {
t.Fatalf("expected %v, got %v", tc.wantErr, err)
}
for _, diagnostic := range tc.diagnostics {
if !strings.Contains(err.Error(), diagnostic) {
t.Fatalf("expected error to contain %q, got %v", diagnostic, err)
}
}
return
}
if err != nil {
t.Fatalf("load selected prompt: %v", err)
}
if got.ID != "selected-prompt" || got.Version != "1" || got.Templates[0].Content != "selected" {
t.Fatalf("unexpected selected definition: %+v", got)
}
})
}
}
}
func TestPromptRepositoryRequiresOneYAMLDocument(t *testing.T) {
const definition = `
id: one-document
version: "1"
messages:
- role: user
content: selected
output:
format: text
validation_mode: none
`
tests := []struct {
name string
suffix string
wantErr bool
}{
{name: "comments and trailing whitespace", suffix: "\n# trailing comment\n\n"},
{name: "second populated document", suffix: "\n---\nid: another\n", wantErr: true},
{name: "second empty document", suffix: "\n---\n", wantErr: true},
{name: "malformed trailing YAML", suffix: "\n---\n[", wantErr: true},
}
for _, source := range promptRepositorySources() {
for _, tc := range tests {
t.Run(source.name+"/"+tc.name, func(t *testing.T) {
repo := source.newRepository(t, map[string]string{"definition.yaml": definition + tc.suffix})
_, err := repo.GetPromptDefinition(context.Background(), "one-document", "1")
if tc.wantErr {
if !errors.Is(err, ErrInvalidYAML) {
t.Fatalf("expected ErrInvalidYAML, got %v", err)
}
if !strings.Contains(err.Error(), "definition.yaml") {
t.Fatalf("expected source path in error, got %v", err)
}
return
}
if err != nil {
t.Fatalf("load one document: %v", err)
}
})
}
}
}
func TestPromptRepositoryReadsOnlySelectedContent(t *testing.T) {
fsys := &recordingFS{FS: fstest.MapFS{
"target.yaml": &fstest.MapFile{Data: []byte(`
id: target
version: "1"
messages:
- role: user
content_file: target.tmpl
output:
format: text
validation_mode: none
`)},
"target.tmpl": &fstest.MapFile{Data: []byte("selected")},
"unrelated.yaml": &fstest.MapFile{Data: []byte(`
id: unrelated
version: "1"
messages:
- role: user
content_file: unrelated.tmpl
output:
format: text
validation_mode: none
`)},
"unrelated.tmpl": &fstest.MapFile{Data: []byte("unrelated")},
"other-version.yaml": &fstest.MapFile{Data: []byte(`
id: target
version: "2"
messages:
- role: user
content_file: other-version.tmpl
output:
format: text
validation_mode: none
`)},
"other-version.tmpl": &fstest.MapFile{Data: []byte("other version")},
}}
repo := NewFSRepository(fsys, ".")
for lookup := 1; lookup <= 2; lookup++ {
got, err := repo.GetPromptDefinition(context.Background(), "target", "1")
if err != nil {
t.Fatalf("lookup %d: %v", lookup, err)
}
if got.Templates[0].Content != "selected" {
t.Fatalf("lookup %d content = %q", lookup, got.Templates[0].Content)
}
if count := fsys.openCount("target.tmpl"); count != lookup {
t.Fatalf("selected content opens after lookup %d = %d, want %d", lookup, count, lookup)
}
for _, name := range []string{"unrelated.tmpl", "other-version.tmpl"} {
if count := fsys.openCount(name); count != 0 {
t.Fatalf("unselected content %q opened %d times", name, count)
}
}
for _, name := range []string{"target.yaml", "unrelated.yaml", "other-version.yaml"} {
if count := fsys.openCount(name); count != lookup {
t.Fatalf("metadata %q opens after lookup %d = %d, want %d", name, lookup, count, lookup)
}
}
}
}
func TestPromptDefinitionNormalizationRules(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
output string definition string
useFilesystem bool
wantErr bool wantErr bool
wantDiagnostic string wantDiagnostic string
wantSchemaPath string wantSchemaPath string
}{ }{
{ {
name: "operating-system source rejects unsupported format", name: "missing version",
output: " format: binary\n validation_mode: none\n", definition: `
useFilesystem: true, id: normalization-rule
messages:
- role: user
content: test
output:
format: text
validation_mode: none
`,
wantErr: true,
wantDiagnostic: "version",
},
{
name: "blank input name",
definition: `
id: normalization-rule
version: "1"
inputs:
- name: " "
messages:
- role: user
content: test
output:
format: text
validation_mode: none
`,
wantErr: true,
wantDiagnostic: "input 0",
},
{
name: "blank message role",
definition: `
id: normalization-rule
version: "1"
messages:
- role: " "
content: test
output:
format: text
validation_mode: none
`,
wantErr: true,
wantDiagnostic: "role",
},
{
name: "invalid output format",
definition: `
id: normalization-rule
version: "1"
messages:
- role: user
content: test
output:
format: binary
validation_mode: none
`,
wantErr: true, wantErr: true,
wantDiagnostic: "format", wantDiagnostic: "format",
}, },
{ {
name: "fs source rejects negative repair attempts", name: "negative repair attempts",
output: " format: text\n validation_mode: none\n repair_attempts: -1\n", definition: `
id: normalization-rule
version: "1"
messages:
- role: user
content: test
output:
format: text
validation_mode: none
repair_attempts: -1
`,
wantErr: true, wantErr: true,
wantDiagnostic: "repair_attempts", wantDiagnostic: "repair_attempts",
}, },
{ {
name: "source normalization trims a valid schema path", name: "explicit blank default profile",
output: " format: json\n validation_mode: json_schema\n schema_path: ' schema.json '\n", definition: `
useFilesystem: true, id: normalization-rule
version: "1"
default_profile: " "
messages:
- role: user
content: test
output:
format: text
validation_mode: none
`,
wantErr: true,
wantDiagnostic: "default_profile",
},
{
name: "schema path normalization",
definition: `
id: normalization-rule
version: "1"
messages:
- role: user
content: test
output:
format: json
validation_mode: json_schema
schema_path: ' schema.json '
`,
wantSchemaPath: "schema.json", wantSchemaPath: "schema.json",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
data := `id: output-contract repo := NewFSRepository(fstest.MapFS{
version: "1" "definition.yaml": &fstest.MapFile{Data: []byte(tt.definition)},
messages: }, ".")
- role: user
content: test
output:
` + tt.output
var repo Repository got, err := repo.GetPromptDefinition(context.Background(), "normalization-rule", "")
if tt.useFilesystem {
dir := t.TempDir()
writePromptTestFile(t, filepath.Join(dir, "output-contract.yaml"), data)
repo = NewFilesystemRepository(dir)
} else {
repo = NewFSRepository(fstest.MapFS{
"output-contract.yaml": &fstest.MapFile{Data: []byte(data)},
}, ".")
}
got, err := repo.GetPromptDefinition(context.Background(), "output-contract", "")
if tt.wantErr { if tt.wantErr {
if !errors.Is(err, ErrInvalidPromptDefinition) { if !errors.Is(err, ErrInvalidPromptDefinition) {
t.Fatalf("expected ErrInvalidPromptDefinition, got %v", err) t.Fatalf("expected ErrInvalidPromptDefinition, got %v", err)
@@ -579,6 +866,42 @@ output:
} }
} }
type promptRepositorySource struct {
name string
newRepository func(t *testing.T, files map[string]string) Repository
}
func promptRepositorySources() []promptRepositorySource {
return []promptRepositorySource{
{
name: "operating system",
newRepository: func(t *testing.T, files map[string]string) Repository {
t.Helper()
root := t.TempDir()
for name, content := range files {
filePath := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
t.Fatalf("create prompt directory: %v", err)
}
writePromptTestFile(t, filePath, content)
}
return NewFilesystemRepository(root)
},
},
{
name: "filesystem",
newRepository: func(t *testing.T, files map[string]string) Repository {
t.Helper()
fsys := make(fstest.MapFS, len(files))
for name, content := range files {
fsys[name] = &fstest.MapFile{Data: []byte(strings.TrimLeft(content, "\n"))}
}
return NewFSRepository(fsys, ".")
},
},
}
}
func assertCacheControl(t *testing.T, got *domain.CacheControl, wantType domain.CacheControlType, wantTTL string) { func assertCacheControl(t *testing.T, got *domain.CacheControl, wantType domain.CacheControlType, wantTTL string) {
t.Helper() t.Helper()
if got == nil { if got == nil {