package sharedassets import ( "bytes" "fmt" "io" "io/fs" "path" "sort" "strings" "time" ) // ModulePromptFile maps a module-owned embedded prompt file into the // Scriptorium-visible module prompt directory. type ModulePromptFile struct { Name string Path string } // SharedPromptFile maps a caller-owned shared prompt file into a module's // Scriptorium-visible 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(promptMapFS, len(files)+len(sharedFiles)) 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) } 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["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...) } 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) } 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["assets/prompts/"+cleanModuleDir+"/sharedassets/"+name] = append([]byte(nil), data...) } return assets, nil } type promptMapFS map[string][]byte func (m promptMapFS) Open(name string) (fs.File, error) { cleaned, err := cleanPromptFSPath(name) if err != nil { return nil, &fs.PathError{Op: "open", Path: name, Err: err} } if data, ok := m[cleaned]; ok { return &promptFile{ reader: bytes.NewReader(data), info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))}, }, nil } entries := m.dirEntries(cleaned) if entries != nil { return &promptDir{name: path.Base(cleaned), entries: entries}, nil } return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} } func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) { cleaned, err := cleanPromptFSPath(name) if err != nil { return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} } entries := m.dirEntries(cleaned) if entries == nil { return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} } return entries, nil } func (m promptMapFS) dirEntries(dir string) []fs.DirEntry { children := map[string]promptDirEntry{} prefix := "" if dir != "." { prefix = dir + "/" } for name, data := range m { if !strings.HasPrefix(name, prefix) { continue } rest := strings.TrimPrefix(name, prefix) if rest == "" { continue } childName, _, hasSlash := strings.Cut(rest, "/") entry := promptDirEntry{name: childName, dir: hasSlash} if !hasSlash { entry.size = int64(len(data)) } children[childName] = entry } if len(children) == 0 { return nil } names := make([]string, 0, len(children)) for name := range children { names = append(names, name) } sort.Strings(names) entries := make([]fs.DirEntry, 0, len(names)) for _, name := range names { entries = append(entries, children[name]) } return entries } 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 } func cleanPromptFSPath(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 == "." { return cleaned, nil } if !fs.ValidPath(cleaned) { return "", fmt.Errorf("invalid path %q", name) } return cleaned, nil } type promptFile struct { reader *bytes.Reader info promptFileInfo } func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil } func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) } func (f *promptFile) Close() error { return nil } type promptDir struct { name string offset int entries []fs.DirEntry } func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil } func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF } func (d *promptDir) Close() error { return nil } func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) { if d.offset >= len(d.entries) { return nil, io.EOF } end := len(d.entries) if n > 0 && d.offset+n < end { end = d.offset + n } out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...) d.offset = end return out, nil } type promptDirEntry struct { name string dir bool size int64 } func (e promptDirEntry) Name() string { return e.name } func (e promptDirEntry) IsDir() bool { return e.dir } func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() } func (e promptDirEntry) Info() (fs.FileInfo, error) { return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil } func (e promptDirEntry) fileInfoMode() fs.FileMode { if e.dir { return fs.ModeDir | 0o555 } return 0o444 } type promptFileInfo struct { name string dir bool size int64 } func (i promptFileInfo) Name() string { return i.name } func (i promptFileInfo) Size() int64 { return i.size } func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() } func (i promptFileInfo) ModTime() time.Time { return time.Time{} } func (i promptFileInfo) IsDir() bool { return i.dir } func (i promptFileInfo) Sys() any { return nil }