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 }