Share YAML catalog helpers

This commit is contained in:
2026-07-04 23:37:07 +00:00
parent 5c882f26a9
commit 93a76f1d36
4 changed files with 161 additions and 114 deletions

View File

@@ -2,7 +2,9 @@ package filecatalog
import ( import (
"context" "context"
"io/fs"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -23,7 +25,7 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
if !isYAMLFile(d.Name()) { if !IsYAMLFile(d.Name()) {
return nil return nil
} }
files = append(files, path) files = append(files, path)
@@ -33,15 +35,64 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
return files, err return files, err
} }
// FindFSYAMLFiles returns sorted paths for .yaml and .yml files under root in fsys.
func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
cleanRoot := CleanFSRoot(root)
var files []string
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if d.IsDir() {
return nil
}
if !IsYAMLFile(d.Name()) {
return nil
}
files = append(files, name)
return nil
})
sort.Strings(files)
return files, err
}
// RelativePath computes a clean relative path from root to path. // RelativePath computes a clean relative path from root to path.
func RelativePath(root string, path string) string { func RelativePath(root string, filePath string) string {
rel, err := filepath.Rel(root, path) rel, err := filepath.Rel(root, filePath)
if err != nil { if err != nil {
return filepath.Clean(path) return filepath.Clean(filePath)
} }
return filepath.Clean(rel) return filepath.Clean(rel)
} }
// CleanFSRoot normalizes a root path for use with fs.FS.
func CleanFSRoot(root string) string {
root = strings.TrimSpace(root)
if root == "" || root == "." {
return "."
}
return path.Clean(root)
}
// DisplayPath returns name relative to root for messages about fs.FS paths.
func DisplayPath(root string, name string) string {
cleanRoot := CleanFSRoot(root)
cleanName := path.Clean(name)
if cleanRoot == "." {
return cleanName
}
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
if strings.HasPrefix(cleanName, prefix) {
return strings.TrimPrefix(cleanName, prefix)
}
return cleanName
}
// Stem strips .yaml or .yml from a file name. // Stem strips .yaml or .yml from a file name.
func Stem(name string) string { func Stem(name string) string {
name = strings.TrimSuffix(name, ".yaml") name = strings.TrimSuffix(name, ".yaml")
@@ -49,6 +100,6 @@ func Stem(name string) string {
return name return name
} }
func isYAMLFile(name string) bool { func IsYAMLFile(name string) bool {
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
} }

View File

@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"testing" "testing"
"testing/fstest"
) )
func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) { func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) {
@@ -43,6 +44,42 @@ func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) {
} }
} }
func TestFindFSYAMLFilesNestedSortedAndFiltered(t *testing.T) {
fsys := fstest.MapFS{
"prompts/z/prompt.yml": &fstest.MapFile{Data: []byte("id: z")},
"prompts/a/profile.yaml": &fstest.MapFile{Data: []byte("id: a")},
"prompts/a/ignore.txt": &fstest.MapFile{Data: []byte("not yaml")},
"prompts/b/ignore.yaml.bak": &fstest.MapFile{Data: []byte("not yaml")},
"other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")},
}
got, err := FindFSYAMLFiles(context.Background(), fsys, " prompts ")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
want := []string{
"prompts/a/profile.yaml",
"prompts/z/prompt.yml",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
}
}
func TestFindFSYAMLFilesHonorsContextCancellation(t *testing.T) {
fsys := fstest.MapFS{
"one.yaml": &fstest.MapFile{Data: []byte("id: one")},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := FindFSYAMLFiles(ctx, fsys, ".")
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
func TestRelativePathNested(t *testing.T) { func TestRelativePathNested(t *testing.T) {
root := t.TempDir() root := t.TempDir()
path := filepath.Join(root, "nested", "profiles", "local.yaml") path := filepath.Join(root, "nested", "profiles", "local.yaml")
@@ -53,6 +90,47 @@ func TestRelativePathNested(t *testing.T) {
} }
} }
func TestCleanFSRoot(t *testing.T) {
tests := []struct {
name string
root string
want string
}{
{name: "empty", root: "", want: "."},
{name: "dot", root: ".", want: "."},
{name: "trimmed", root: " prompts/../profiles ", want: "profiles"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := CleanFSRoot(tc.root); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestDisplayPath(t *testing.T) {
tests := []struct {
name string
root string
path string
want string
}{
{name: "root dot", root: ".", path: "profiles/local.yaml", want: "profiles/local.yaml"},
{name: "nested root", root: "profiles", path: "profiles/local.yaml", want: "local.yaml"},
{name: "outside root", root: "profiles", path: "other/local.yaml", want: "other/local.yaml"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := DisplayPath(tc.root, tc.path); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestStemStripsYAMLExtensions(t *testing.T) { func TestStemStripsYAMLExtensions(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -73,6 +151,27 @@ func TestStemStripsYAMLExtensions(t *testing.T) {
} }
} }
func TestIsYAMLFile(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{name: "yaml", in: "prompt.yaml", want: true},
{name: "yml", in: "profile.yml", want: true},
{name: "backup", in: "profile.yaml.bak", want: false},
{name: "uppercase", in: "profile.YAML", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := IsYAMLFile(tc.in); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
func mustWriteFile(t *testing.T, path string, content string) { func mustWriteFile(t *testing.T, path string, content string) {
t.Helper() t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

View File

@@ -11,6 +11,7 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -79,7 +80,7 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
return nil, fmt.Errorf("failed to read profile directory: filesystem is nil") return nil, fmt.Errorf("failed to read profile directory: filesystem is nil")
} }
files, err := findProfileYAMLFiles(ctx, fsys, root) files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read profile directory: %w", err) return nil, fmt.Errorf("failed to read profile directory: %w", err)
} }
@@ -92,8 +93,8 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
default: default:
} }
relPath := displayPath(root, fullPath) relPath := filecatalog.DisplayPath(root, fullPath)
fileMatch := profileFileStem(path.Base(fullPath)) == id 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 {
return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err) return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err)
@@ -147,61 +148,6 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
return nil, ErrProfileNotFound return nil, ErrProfileNotFound
} }
func findProfileYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
cleanRoot := cleanFSRoot(root)
var files []string
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if d.IsDir() {
return nil
}
if !isProfileYAMLFile(d.Name()) {
return nil
}
files = append(files, name)
return nil
})
return files, err
}
func cleanFSRoot(root string) string {
root = strings.TrimSpace(root)
if root == "" || root == "." {
return "."
}
return path.Clean(root)
}
func displayPath(root string, name string) string {
cleanRoot := cleanFSRoot(root)
cleanName := path.Clean(name)
if cleanRoot == "." {
return cleanName
}
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
if strings.HasPrefix(cleanName, prefix) {
return strings.TrimPrefix(cleanName, prefix)
}
return cleanName
}
func profileFileStem(name string) string {
name = strings.TrimSuffix(name, ".yaml")
name = strings.TrimSuffix(name, ".yml")
return name
}
func isProfileYAMLFile(name string) bool {
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
}
type profileMatch struct { type profileMatch struct {
profile *domain.ExecutionProfile profile *domain.ExecutionProfile
path string path string

View File

@@ -189,7 +189,7 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil") return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil")
} }
files, err := findPromptDefinitionYAMLFiles(ctx, fsys, root) files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err) return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
} }
@@ -202,7 +202,7 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
default: default:
} }
relPath := displayPath(root, fullPath) relPath := filecatalog.DisplayPath(root, fullPath)
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id 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 {
@@ -258,55 +258,6 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
return nil, ErrPromptDefinitionNotFound return nil, ErrPromptDefinitionNotFound
} }
func findPromptDefinitionYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
cleanRoot := cleanFSRoot(root)
var files []string
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if d.IsDir() {
return nil
}
if !isPromptDefinitionYAMLFile(d.Name()) {
return nil
}
files = append(files, name)
return nil
})
return files, err
}
func cleanFSRoot(root string) string {
root = strings.TrimSpace(root)
if root == "" || root == "." {
return "."
}
return path.Clean(root)
}
func displayPath(root string, name string) string {
cleanRoot := cleanFSRoot(root)
cleanName := path.Clean(name)
if cleanRoot == "." {
return cleanName
}
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
if strings.HasPrefix(cleanName, prefix) {
return strings.TrimPrefix(cleanName, prefix)
}
return cleanName
}
func isPromptDefinitionYAMLFile(name string) bool {
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
}
func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) { func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
var raw promptDefinitionFile var raw promptDefinitionFile
decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder := yaml.NewDecoder(bytes.NewReader(data))