diff --git a/internal/profile/filesystem_repository.go b/internal/profile/filesystem_repository.go index 5d219ee..d736351 100644 --- a/internal/profile/filesystem_repository.go +++ b/internal/profile/filesystem_repository.go @@ -5,12 +5,12 @@ import ( "context" "errors" "fmt" + "io/fs" "os" - "path/filepath" + "path" "strings" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" - "gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog" "gopkg.in/yaml.v3" ) @@ -30,11 +30,56 @@ func NewFilesystemRepository(dir string) Repository { } func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) { + return loadProfile(ctx, os.DirFS(r.dir), ".", id) +} + +type fsRepository struct { + fsys fs.FS + root string +} + +func NewFSRepository(fsys fs.FS, root string) Repository { + return &fsRepository{fsys: fsys, root: root} +} + +func (r *fsRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) { + return loadProfile(ctx, r.fsys, r.root, id) +} + +type overlayRepository struct { + primary Repository + fallback Repository +} + +func NewOverlayRepository(primary, fallback Repository) Repository { + return &overlayRepository{primary: primary, fallback: fallback} +} + +func (r *overlayRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) { + if r.primary != nil { + prof, err := r.primary.GetProfile(ctx, id) + if err == nil { + return prof, nil + } + if !errors.Is(err, ErrProfileNotFound) { + return nil, err + } + } + if r.fallback == nil { + return nil, ErrProfileNotFound + } + return r.fallback.GetProfile(ctx, id) +} + +func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*domain.ExecutionProfile, error) { if strings.TrimSpace(id) == "" { return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile) } + if fsys == nil { + return nil, fmt.Errorf("failed to read profile directory: filesystem is nil") + } - files, err := filecatalog.FindYAMLFiles(ctx, r.dir) + files, err := findProfileYAMLFiles(ctx, fsys, root) if err != nil { return nil, fmt.Errorf("failed to read profile directory: %w", err) } @@ -47,9 +92,9 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*doma default: } - relPath := filecatalog.RelativePath(r.dir, fullPath) - fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id - data, err := os.ReadFile(fullPath) + relPath := displayPath(root, fullPath) + fileMatch := profileFileStem(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) } @@ -102,6 +147,61 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, 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/profile/repository_test.go b/internal/profile/repository_test.go index b970560..f06b3d2 100644 --- a/internal/profile/repository_test.go +++ b/internal/profile/repository_test.go @@ -8,6 +8,9 @@ import ( "path/filepath" "strings" "testing" + "testing/fstest" + + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" ) func TestFilesystemRepository_GetProfile(t *testing.T) { @@ -261,3 +264,216 @@ func writeProfileTestFile(t *testing.T, path string, content string) { t.Fatalf("failed to write profile test file %q: %v", path, err) } } + +func TestFSRepository(t *testing.T) { + ctx := context.Background() + + t.Run("loads valid profiles from nested directories", func(t *testing.T) { + repo := NewFSRepository(fstest.MapFS{ + "profiles/provider/nested.yaml": profileMapFile(` +id: nested-profile +endpoint: http://localhost:8000/v1 +model: nested-model +temperature: 0.1 +`), + }, "profiles") + + p, err := repo.GetProfile(ctx, "nested-profile") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if p.ID != "nested-profile" || p.Model != "nested-model" { + t.Fatalf("unexpected profile: %+v", p) + } + }) + + t.Run("rejects unknown YAML fields", func(t *testing.T) { + repo := NewFSRepository(fstest.MapFS{ + "profiles/unknown.yaml": profileMapFile(` +id: unknown-profile +endpoint: http://localhost:8000/v1 +model: model +unknown: value +`), + }, "profiles") + + _, err := repo.GetProfile(ctx, "unknown-profile") + if !errors.Is(err, ErrInvalidYAML) { + t.Fatalf("expected ErrInvalidYAML, got %v", err) + } + }) + + t.Run("rejects raw api_key in selected profile", func(t *testing.T) { + repo := NewFSRepository(fstest.MapFS{ + "profiles/raw.yaml": profileMapFile(` +id: raw-profile +endpoint: http://localhost:8000/v1 +model: model +api_key: secret +`), + }, "profiles") + + _, err := repo.GetProfile(ctx, "raw-profile") + if !errors.Is(err, ErrRawAPIKeyNotAllowed) { + t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err) + } + }) + + t.Run("ignores raw api_key in non-selected profiles", func(t *testing.T) { + repo := NewFSRepository(fstest.MapFS{ + "profiles/raw.yaml": profileMapFile(` +id: raw-profile +endpoint: http://localhost:8000/v1 +model: model +api_key: secret +`), + "profiles/valid.yaml": profileMapFile(` +id: valid-profile +endpoint: http://localhost:8000/v1 +model: model +`), + }, "profiles") + + p, err := repo.GetProfile(ctx, "valid-profile") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if p.ID != "valid-profile" { + t.Fatalf("unexpected profile: %+v", p) + } + }) + + t.Run("rejects duplicate IDs within one source", func(t *testing.T) { + repo := NewFSRepository(fstest.MapFS{ + "profiles/a.yaml": profileMapFile(` +id: duplicate-profile +endpoint: http://localhost:8000/v1 +model: first +`), + "profiles/nested/b.yaml": profileMapFile(` +id: duplicate-profile +endpoint: http://localhost:8000/v1 +model: second +`), + }, "profiles") + + _, err := repo.GetProfile(ctx, "duplicate-profile") + if !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("expected ErrInvalidProfile, got %v", err) + } + for _, want := range []string{"duplicate execution profile id", "a.yaml", "nested/b.yaml"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected error to contain %q, got %v", want, err) + } + } + }) +} + +func TestOverlayRepository(t *testing.T) { + ctx := context.Background() + primaryProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://primary", Model: "primary"} + fallbackProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://fallback", Model: "fallback"} + + t.Run("returns primary matches before fallback matches", func(t *testing.T) { + repo := NewOverlayRepository( + staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": primaryProfile}}, + staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}}, + ) + + p, err := repo.GetProfile(ctx, "shared") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if p.Model != "primary" { + t.Fatalf("expected primary profile, got %+v", p) + } + }) + + t.Run("falls back on primary not found", func(t *testing.T) { + repo := NewOverlayRepository( + staticProfileRepo{}, + staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}}, + ) + + p, err := repo.GetProfile(ctx, "shared") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if p.Model != "fallback" { + t.Fatalf("expected fallback profile, got %+v", p) + } + }) + + t.Run("does not fall back after primary load errors", func(t *testing.T) { + for _, tc := range []struct { + name string + err error + }{ + {name: "invalid yaml", err: ErrInvalidYAML}, + {name: "invalid profile", err: ErrInvalidProfile}, + {name: "raw api key", err: ErrRawAPIKeyNotAllowed}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := NewOverlayRepository( + staticProfileRepo{err: tc.err}, + staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}}, + ) + + _, err := repo.GetProfile(ctx, "shared") + if !errors.Is(err, tc.err) { + t.Fatalf("expected %v, got %v", tc.err, err) + } + }) + } + }) + + t.Run("returns not found when both sources miss", func(t *testing.T) { + repo := NewOverlayRepository(staticProfileRepo{}, staticProfileRepo{}) + + _, err := repo.GetProfile(ctx, "missing") + if !errors.Is(err, ErrProfileNotFound) { + t.Fatalf("expected ErrProfileNotFound, got %v", err) + } + }) + + t.Run("nil primary uses fallback", func(t *testing.T) { + repo := NewOverlayRepository(nil, staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}}) + + p, err := repo.GetProfile(ctx, "shared") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if p.Model != "fallback" { + t.Fatalf("expected fallback profile, got %+v", p) + } + }) + + t.Run("nil fallback returns not found after primary miss", func(t *testing.T) { + repo := NewOverlayRepository(staticProfileRepo{}, nil) + + _, err := repo.GetProfile(ctx, "missing") + if !errors.Is(err, ErrProfileNotFound) { + t.Fatalf("expected ErrProfileNotFound, got %v", err) + } + }) +} + +func profileMapFile(content string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte(strings.TrimLeft(content, "\n"))} +} + +type staticProfileRepo struct { + profiles map[string]*domain.ExecutionProfile + err error +} + +func (r staticProfileRepo) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) { + if r.err != nil { + return nil, r.err + } + if p, ok := r.profiles[id]; ok { + cp := *p + return &cp, nil + } + return nil, ErrProfileNotFound +}