109 lines
3.6 KiB
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
|
|
}
|