diff --git a/internal/filecatalog/catalog.go b/internal/filecatalog/catalog.go new file mode 100644 index 0000000..3d41b6b --- /dev/null +++ b/internal/filecatalog/catalog.go @@ -0,0 +1,54 @@ +package filecatalog + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" +) + +// FindYAMLFiles returns sorted full paths for .yaml and .yml files under root. +func FindYAMLFiles(ctx context.Context, root string) ([]string, error) { + var files []string + err := filepath.WalkDir(root, func(path string, d os.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, path) + 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) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(rel) +} + +// Stem strips .yaml or .yml from a file name. +func Stem(name string) string { + name = strings.TrimSuffix(name, ".yaml") + name = strings.TrimSuffix(name, ".yml") + return name +} + +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 new file mode 100644 index 0000000..02d242b --- /dev/null +++ b/internal/filecatalog/catalog_test.go @@ -0,0 +1,84 @@ +package filecatalog + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "z", "prompt.yml"), "id: z") + mustWriteFile(t, filepath.Join(root, "a", "profile.yaml"), "id: a") + mustWriteFile(t, filepath.Join(root, "a", "ignore.txt"), "not yaml") + mustWriteFile(t, filepath.Join(root, "b", "ignore.yaml.bak"), "not yaml") + + got, err := FindYAMLFiles(context.Background(), root) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + want := []string{ + filepath.Join(root, "a", "profile.yaml"), + filepath.Join(root, "z", "prompt.yml"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected sorted YAML files %v, got %v", want, got) + } +} + +func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "one.yaml"), "id: one") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := FindYAMLFiles(ctx, root) + 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") + got := RelativePath(root, path) + want := filepath.Join("nested", "profiles", "local.yaml") + if got != want { + t.Fatalf("expected relative path %q, got %q", want, got) + } +} + +func TestStemStripsYAMLExtensions(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "yaml", in: "prompt.yaml", want: "prompt"}, + {name: "yml", in: "profile.yml", want: "profile"}, + {name: "other", in: "file.txt", want: "file.txt"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Stem(tc.in); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func mustWriteFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create directory: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file %q: %v", path, err) + } +} diff --git a/internal/profile/filesystem_repository.go b/internal/profile/filesystem_repository.go index 86919e2..5d3f321 100644 --- a/internal/profile/filesystem_repository.go +++ b/internal/profile/filesystem_repository.go @@ -7,10 +7,10 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + "gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog" "gopkg.in/yaml.v3" ) @@ -34,7 +34,7 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*doma return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile) } - files, err := r.yamlFiles(ctx) + files, err := filecatalog.FindYAMLFiles(ctx, r.dir) if err != nil { return nil, fmt.Errorf("failed to read profile directory: %w", err) } @@ -47,8 +47,8 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*doma default: } - relPath := r.relativePath(fullPath) - fileMatch := profileIDFromFileName(filepath.Base(fullPath)) == id + relPath := filecatalog.RelativePath(r.dir, fullPath) + fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id data, err := os.ReadFile(fullPath) if err != nil { return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err) @@ -106,48 +106,6 @@ type profileMatch struct { path string } -func (r *filesystemRepository) yamlFiles(ctx context.Context) ([]string, error) { - var files []string - err := filepath.WalkDir(r.dir, func(path string, d os.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, path) - return nil - }) - sort.Strings(files) - return files, err -} - -func (r *filesystemRepository) relativePath(path string) string { - rel, err := filepath.Rel(r.dir, path) - if err != nil { - return filepath.Clean(path) - } - return filepath.Clean(rel) -} - -func isYAMLFile(name string) bool { - return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") -} - -func profileIDFromFileName(name string) string { - name = strings.TrimSuffix(name, ".yaml") - name = strings.TrimSuffix(name, ".yml") - return name -} - func profileFileHasID(data []byte, id string) bool { var raw struct { ID string `yaml:"id"` diff --git a/internal/promptdef/filesystem_repository.go b/internal/promptdef/filesystem_repository.go index 6572995..422d0fc 100644 --- a/internal/promptdef/filesystem_repository.go +++ b/internal/promptdef/filesystem_repository.go @@ -7,10 +7,10 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + "gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog" "gopkg.in/yaml.v3" ) @@ -63,7 +63,7 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition) } - files, err := r.yamlFiles(ctx) + files, err := filecatalog.FindYAMLFiles(ctx, r.dir) if err != nil { return nil, fmt.Errorf("failed to read prompt definition directory: %w", err) } @@ -76,8 +76,8 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin default: } - relPath := r.relativePath(fullPath) - fileMatch := promptIDFromFileName(filepath.Base(fullPath)) == id + relPath := filecatalog.RelativePath(r.dir, fullPath) + fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id raw, err := loadPromptDefinitionFile(fullPath) if err != nil { @@ -130,38 +130,6 @@ type promptDefinitionMatch struct { path string } -func (r *filesystemRepository) yamlFiles(ctx context.Context) ([]string, error) { - var files []string - err := filepath.WalkDir(r.dir, func(path string, d os.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, path) - return nil - }) - sort.Strings(files) - return files, err -} - -func (r *filesystemRepository) relativePath(path string) string { - rel, err := filepath.Rel(r.dir, path) - if err != nil { - return filepath.Clean(path) - } - return filepath.Clean(rel) -} - func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) { data, err := os.ReadFile(path) if err != nil { @@ -306,16 +274,6 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d }, nil } -func isYAMLFile(name string) bool { - return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") -} - -func promptIDFromFileName(name string) string { - name = strings.TrimSuffix(name, ".yaml") - name = strings.TrimSuffix(name, ".yml") - return name -} - func isValidOutputFormat(f domain.OutputFormat) bool { switch f { case domain.FormatText, domain.FormatMarkdown, domain.FormatJSON: