From 93a76f1d36d1583205e9363e3d310fb51545a700 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 4 Jul 2026 23:37:07 +0000 Subject: [PATCH] Share YAML catalog helpers --- internal/filecatalog/catalog.go | 61 +++++++++++-- internal/filecatalog/catalog_test.go | 99 +++++++++++++++++++++ internal/profile/filesystem_repository.go | 62 +------------ internal/promptdef/filesystem_repository.go | 53 +---------- 4 files changed, 161 insertions(+), 114 deletions(-) diff --git a/internal/filecatalog/catalog.go b/internal/filecatalog/catalog.go index 3d41b6b..16c9272 100644 --- a/internal/filecatalog/catalog.go +++ b/internal/filecatalog/catalog.go @@ -2,7 +2,9 @@ package filecatalog import ( "context" + "io/fs" "os" + "path" "path/filepath" "sort" "strings" @@ -23,7 +25,7 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) { if d.IsDir() { return nil } - if !isYAMLFile(d.Name()) { + if !IsYAMLFile(d.Name()) { return nil } files = append(files, path) @@ -33,15 +35,64 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) { 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. -func RelativePath(root string, path string) string { - rel, err := filepath.Rel(root, path) +func RelativePath(root string, filePath string) string { + rel, err := filepath.Rel(root, filePath) if err != nil { - return filepath.Clean(path) + return filepath.Clean(filePath) } 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. func Stem(name string) string { name = strings.TrimSuffix(name, ".yaml") @@ -49,6 +100,6 @@ func Stem(name string) string { return name } -func isYAMLFile(name string) bool { +func IsYAMLFile(name string) bool { return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") } diff --git a/internal/filecatalog/catalog_test.go b/internal/filecatalog/catalog_test.go index 02d242b..0ba8015 100644 --- a/internal/filecatalog/catalog_test.go +++ b/internal/filecatalog/catalog_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "reflect" "testing" + "testing/fstest" ) 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) { root := t.TempDir() 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) { tests := []struct { 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) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/internal/profile/filesystem_repository.go b/internal/profile/filesystem_repository.go index d736351..c6508ea 100644 --- a/internal/profile/filesystem_repository.go +++ b/internal/profile/filesystem_repository.go @@ -11,6 +11,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + "gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog" "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") } - files, err := findProfileYAMLFiles(ctx, fsys, root) + files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root) if err != nil { 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: } - relPath := displayPath(root, fullPath) - fileMatch := profileFileStem(path.Base(fullPath)) == id + relPath := filecatalog.DisplayPath(root, fullPath) + fileMatch := filecatalog.Stem(path.Base(fullPath)) == id data, err := fs.ReadFile(fsys, fullPath) if err != nil { 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 } -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 { profile *domain.ExecutionProfile path string diff --git a/internal/promptdef/filesystem_repository.go b/internal/promptdef/filesystem_repository.go index f4cf6e3..00234f3 100644 --- a/internal/promptdef/filesystem_repository.go +++ b/internal/promptdef/filesystem_repository.go @@ -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") } - files, err := findPromptDefinitionYAMLFiles(ctx, fsys, root) + files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root) if err != nil { 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: } - relPath := displayPath(root, fullPath) + relPath := filecatalog.DisplayPath(root, fullPath) fileMatch := filecatalog.Stem(path.Base(fullPath)) == id data, err := fs.ReadFile(fsys, fullPath) if err != nil { @@ -258,55 +258,6 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin 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) { var raw promptDefinitionFile decoder := yaml.NewDecoder(bytes.NewReader(data))