Files
notarius/internal/framework/promptfs/prompt_fs.go

109 lines
3.6 KiB
Go

package promptfs
import (
"fmt"
"io/fs"
"path"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
)
// ModulePromptFile maps a module-owned embedded prompt file into the registered
// module prompt directory.
type ModulePromptFile struct {
Name string
Path string
}
// SharedPromptFile maps a caller-owned shared prompt file into a module's
// registered sharedassets prompt subdirectory.
type SharedPromptFile struct {
Name string
FS fs.FS
Path string
}
// ModulePromptFS builds a prompt filesystem for a module directory from
// module-owned prompt files plus caller-provided shared prompt files under the
// module's sharedassets subdirectory.
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, sharedFiles ...SharedPromptFile) (fs.FS, error) {
cleanModuleDir, err := cleanPromptPath(moduleDir)
if err != nil {
return nil, fmt.Errorf("module prompt directory: %w", err)
}
if moduleFS == nil {
return nil, fmt.Errorf("module prompt filesystem must not be nil")
}
assets := make(map[string][]byte, len(files)+len(sharedFiles))
moduleDestinations := make(map[string]struct{}, len(files))
for _, file := range files {
name, err := cleanPromptPath(file.Name)
if err != nil {
return nil, fmt.Errorf("module prompt file name %q: %w", file.Name, err)
}
if strings.Contains(name, "/") {
return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.Name)
}
destination := "assets/prompts/" + cleanModuleDir + "/" + name
if _, exists := moduleDestinations[destination]; exists {
return nil, fmt.Errorf("duplicate module prompt destination %q", destination)
}
moduleDestinations[destination] = struct{}{}
filePath, err := cleanPromptPath(file.Path)
if err != nil {
return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err)
}
data, err := fs.ReadFile(moduleFS, filePath)
if err != nil {
return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err)
}
assets[destination] = data
}
sharedDestinations := make(map[string]struct{}, len(sharedFiles))
for _, file := range sharedFiles {
name, err := cleanPromptPath(file.Name)
if err != nil {
return nil, fmt.Errorf("shared prompt file name %q: %w", file.Name, err)
}
if strings.Contains(name, "/") {
return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.Name)
}
destination := "assets/prompts/" + cleanModuleDir + "/sharedassets/" + name
if _, exists := sharedDestinations[destination]; exists {
return nil, fmt.Errorf("duplicate sharedassets prompt destination %q", destination)
}
sharedDestinations[destination] = struct{}{}
if file.FS == nil {
return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name)
}
filePath, err := cleanPromptPath(file.Path)
if err != nil {
return nil, fmt.Errorf("shared prompt file path %q: %w", file.Path, err)
}
data, err := fs.ReadFile(file.FS, filePath)
if err != nil {
return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err)
}
assets[destination] = data
}
fsys, err := readonlyfs.New(assets)
if err != nil {
return nil, fmt.Errorf("construct prompt filesystem: %w", err)
}
return fsys, nil
}
func cleanPromptPath(name string) (string, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "", fmt.Errorf("path must not be empty")
}
cleaned := path.Clean(strings.TrimPrefix(trimmed, "/"))
if cleaned == "." || !fs.ValidPath(cleaned) {
return "", fmt.Errorf("invalid path %q", name)
}
return cleaned, nil
}