diff --git a/internal/setup/detector.go b/internal/setup/detector.go new file mode 100644 index 00000000..cf201c8a --- /dev/null +++ b/internal/setup/detector.go @@ -0,0 +1,351 @@ +package setup + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// DetectResult contains information about the user's project detected from the working directory. +type DetectResult struct { + Language string `json:"language"` + Framework string `json:"framework,omitempty"` + PackageManager string `json:"package_manager"` + SDKID string `json:"sdk_id"` + EntryPoint string `json:"entry_point"` +} + +// Detector inspects a directory to determine the language, framework, package manager, +// recommended SDK, and entry point file. +type Detector interface { + Detect(dir string) (*DetectResult, error) +} + +// StubDetector is a placeholder implementation. Replace with real detection logic. +type StubDetector struct{} + +var _ Detector = StubDetector{} + +func (StubDetector) Detect(_ string) (*DetectResult, error) { + return nil, errors.New("detect is not yet implemented: a real Detector must be provided") +} + +// FileDetector implements Detector by scanning the filesystem for known project indicators. +type FileDetector struct{} + +var _ Detector = FileDetector{} + +// Detect scans dir for known project files and returns a DetectResult with language, +// framework, SDK ID, package manager, and a suggested entry point file. +// Returns an error if the project type cannot be determined. +func (FileDetector) Detect(dir string) (*DetectResult, error) { + if result := detectNode(dir); result != nil { + return result, nil + } + if result := detectGo(dir); result != nil { + return result, nil + } + if result := detectPython(dir); result != nil { + return result, nil + } + if result := detectRuby(dir); result != nil { + return result, nil + } + if result := detectJava(dir); result != nil { + return result, nil + } + if result := detectSwift(dir); result != nil { + return result, nil + } + if result := detectDotnet(dir); result != nil { + return result, nil + } + return nil, errors.New("could not detect project language from directory; try specifying --sdk-id manually") +} + +func detectNode(dir string) *DetectResult { + pkgBytes, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return nil + } + + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if json.Unmarshal(pkgBytes, &pkg) != nil { + return nil + } + + allDeps := make(map[string]string, len(pkg.Dependencies)+len(pkg.DevDependencies)) + for k, v := range pkg.Dependencies { + allDeps[k] = v + } + for k, v := range pkg.DevDependencies { + allDeps[k] = v + } + + pm := detectNodePM(dir) + + // Next.js apps run a Node server (SSR and API routes), so server-side flag + // evaluation uses the Node server SDK rather than a browser client SDK. + if _, ok := allDeps["next"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "Next.js", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "pages/index.tsx", "pages/index.ts", "pages/index.js", + "index.js", + })), + } + } + + if _, ok := allDeps["react-native"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React Native", + PackageManager: pm, + SDKID: "react-native", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + if _, ok := allDeps["react"]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: "React", + PackageManager: pm, + SDKID: "react-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "index.js", + })), + } + } + jsClientFrameworks := []struct{ dep, framework string }{ + {"backbone", "Backbone"}, + {"svelte", "Svelte"}, + {"vue", "Vue"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, fw := range jsClientFrameworks { + if _, ok := allDeps[fw.dep]; ok { + return &DetectResult{ + Language: "JavaScript", + Framework: fw.framework, + PackageManager: pm, + SDKID: "js-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/App.tsx", "src/App.jsx", "src/App.js", + "src/index.tsx", "src/index.jsx", "src/index.js", + "src/main.ts", "src/main.js", "index.js", + })), + } + } + } + + return &DetectResult{ + Language: "JavaScript", + PackageManager: pm, + SDKID: "node-server", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/index.ts", "src/index.js", + "index.ts", "index.js", + "server.ts", "server.js", + "app.ts", "app.js", + })), + } +} + +func detectNodePM(dir string) string { + if _, err := os.Stat(filepath.Join(dir, "pnpm-lock.yaml")); err == nil { + return "pnpm" + } + if _, err := os.Stat(filepath.Join(dir, "yarn.lock")); err == nil { + return "yarn" + } + if _, err := os.Stat(filepath.Join(dir, "bun.lock")); err == nil { + return "bun" + } + return "npm" +} + +func detectGo(dir string) *DetectResult { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { + return nil + } + return &DetectResult{ + Language: "Go", + PackageManager: "go", + SDKID: "go-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{"cmd/main.go", "main.go"})), + } +} + +func detectPython(dir string) *DetectResult { + for _, indicator := range []string{"requirements.txt", "pyproject.toml", "setup.py"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + return &DetectResult{ + Language: "Python", + PackageManager: "pip", + SDKID: "python-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "src/main.py", "manage.py", "app.py", "main.py", + })), + } + } + } + return nil +} + +func detectRuby(dir string) *DetectResult { + found := false + for _, indicator := range []string{"Gemfile", "Gemfile.lock", "config.ru"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + found = true + break + } + } + if !found { + if matches, _ := filepath.Glob(filepath.Join(dir, "*.gemspec")); len(matches) == 0 { + return nil + } + } + return &DetectResult{ + Language: "Ruby", + PackageManager: "gem", + SDKID: "ruby-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "config.ru", "app.rb", "main.rb", + })), + } +} + +func detectJava(dir string) *DetectResult { + for _, indicator := range []string{"pom.xml", "build.gradle", "build.gradle.kts"} { + if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil { + pm := "gradle" + if indicator == "pom.xml" { + pm = "mvn" + } + // Android projects use Gradle but are distinguished by AndroidManifest.xml. + for _, manifest := range []string{ + "app/src/main/AndroidManifest.xml", + "src/main/AndroidManifest.xml", + } { + if _, err := os.Stat(filepath.Join(dir, manifest)); err == nil { + return &DetectResult{ + Language: "Java", + PackageManager: "gradle", + SDKID: "android-client-sdk", + EntryPoint: filepath.Join(dir, "app/src/main/java/MainActivity.java"), + } + } + } + return &DetectResult{ + Language: "Java", + PackageManager: pm, + SDKID: "java-server-sdk", + EntryPoint: filepath.Join(dir, "src/main/java/Main.java"), + } + } + } + return nil +} + +func detectSwift(dir string) *DetectResult { + pm := "spm" + if _, err := os.Stat(filepath.Join(dir, "Podfile")); err == nil { + pm = "cocoapods" + } + indicators := []string{"Package.swift", "Podfile"} + for _, f := range indicators { + if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + } + matches, _ := filepath.Glob(filepath.Join(dir, "*.xcodeproj")) + if len(matches) > 0 { + return &DetectResult{ + Language: "Swift", + PackageManager: pm, + SDKID: "swift-client-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Sources/main.swift", "App.swift", "ContentView.swift", "AppDelegate.swift", + })), + } + } + return nil +} + +func detectDotnet(dir string) *DetectResult { + for _, pattern := range []string{"*.csproj", "*.sln"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + if len(matches) > 0 { + return &DetectResult{ + Language: "C#", + PackageManager: "dotnet", + SDKID: "dotnet-server-sdk", + EntryPoint: filepath.Join(dir, firstExistingIn(dir, []string{ + "Program.cs", "Startup.cs", "src/Program.cs", + })), + } + } + } + return nil +} + +// SDKOption describes a LaunchDarkly SDK available for use with ldcli setup. +type SDKOption struct { + ID string + Language string + Name string +} + +// KnownSDKs is the ordered list of SDKs available for manual selection when +// auto-detection fails or the user wants to override the detected SDK. +var KnownSDKs = []SDKOption{ + {ID: "node-server", Language: "JavaScript", Name: "Node.js"}, + {ID: "react-client-sdk", Language: "JavaScript", Name: "React"}, + {ID: "react-native", Language: "JavaScript", Name: "React Native"}, + {ID: "js-client-sdk", Language: "JavaScript", Name: "JavaScript (Browser)"}, + {ID: "python-server-sdk", Language: "Python", Name: "Python"}, + {ID: "go-server-sdk", Language: "Go", Name: "Go"}, + {ID: "java-server-sdk", Language: "Java", Name: "Java"}, + {ID: "android-client-sdk", Language: "Java", Name: "Android"}, + {ID: "dotnet-server-sdk", Language: "C#", Name: ".NET"}, + {ID: "swift-client-sdk", Language: "Swift", Name: "iOS/Swift"}, + {ID: "ruby-server-sdk", Language: "Ruby", Name: "Ruby"}, +} + +// firstExistingIn returns the first candidate that exists as a file in dir, +// or the last candidate if none exist (as a suggested path). +// Returns an empty string if candidates is empty. +func firstExistingIn(dir string, candidates []string) string { + if len(candidates) == 0 { + return "" + } + for _, c := range candidates { + if _, err := os.Stat(filepath.Join(dir, c)); err == nil { + return c + } + } + return candidates[len(candidates)-1] +} diff --git a/internal/setup/detector_ruby_test.go b/internal/setup/detector_ruby_test.go new file mode 100644 index 00000000..9dadbbc4 --- /dev/null +++ b/internal/setup/detector_ruby_test.go @@ -0,0 +1,33 @@ +package setup + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileDetector_DetectsRuby_Gemfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n") + writeDetectFile(t, dir, "app.rb", "# app\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", result.SDKID) + assert.Equal(t, "Ruby", result.Language) + assert.Equal(t, "gem", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "app.rb"), result.EntryPoint) +} + +func TestFileDetector_DetectsRuby_Gemspec(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "mygem.gemspec", "Gem::Specification.new\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", result.SDKID) +} diff --git a/internal/setup/detector_test.go b/internal/setup/detector_test.go new file mode 100644 index 00000000..ce8945ea --- /dev/null +++ b/internal/setup/detector_test.go @@ -0,0 +1,357 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeDetectFile writes content to a file in dir, creating parent directories as needed. +func writeDetectFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) +} + +func TestFileDetector_DetectsReact(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + writeDetectFile(t, dir, "src/App.tsx", "// App") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-client-sdk", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React", result.Framework) + assert.Equal(t, "npm", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "src/App.tsx"), result.EntryPoint) +} + +func TestFileDetector_DetectsReactNative(t *testing.T) { + dir := t.TempDir() + // React Native projects always list both "react" and "react-native" as deps; + // react-native must be checked first so it takes priority over react. + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","react-native":"^0.73.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "react-native", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "React Native", result.Framework) +} + +func TestFileDetector_DetectsNextJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0","next":"^14.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Equal(t, "Next.js", result.Framework) +} + +func TestFileDetector_DetectsNodeJs(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"express":"^4.0.0"}}`) + writeDetectFile(t, dir, "index.js", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "node-server", result.SDKID) + assert.Equal(t, "JavaScript", result.Language) + assert.Empty(t, result.Framework) + assert.Equal(t, filepath.Join(dir, "index.js"), result.EntryPoint) +} + +func TestFileDetector_DetectsGo(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "go.mod", "module example.com/myapp\n\ngo 1.21\n") + writeDetectFile(t, dir, "main.go", "package main\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "go-server-sdk", result.SDKID) + assert.Equal(t, "Go", result.Language) + assert.Equal(t, "go", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "main.go"), result.EntryPoint) +} + +func TestFileDetector_DetectsPython_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "requirements.txt", "flask==3.0.0\n") + writeDetectFile(t, dir, "app.py", "# app") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) + assert.Equal(t, "Python", result.Language) + assert.Equal(t, "pip", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "app.py"), result.EntryPoint) +} + +func TestFileDetector_DetectsPython_Pyproject(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pyproject.toml", "[tool.poetry]\nname = \"myapp\"\n") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "python-server-sdk", result.SDKID) +} + +func TestFileDetector_DetectsJava_PomXml(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "pom.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "mvn", result.PackageManager) +} + +func TestFileDetector_DetectsJava_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_BuildGradle(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'com.android.application' }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "Java", result.Language) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsAndroid_KotlinDsl(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle.kts", "plugins { id(\"com.android.application\") }") + writeDetectFile(t, dir, "app/src/main/AndroidManifest.xml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "android-client-sdk", result.SDKID) + assert.Equal(t, "gradle", result.PackageManager) +} + +func TestFileDetector_DetectsJava_NotAndroid(t *testing.T) { + // build.gradle without AndroidManifest.xml should still return java-server-sdk + dir := t.TempDir() + writeDetectFile(t, dir, "build.gradle", "plugins { id 'java' }") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "java-server-sdk", result.SDKID) +} + +func TestFileDetector_UnknownProject_ReturnsError(t *testing.T) { + dir := t.TempDir() + + _, err := FileDetector{}.Detect(dir) + + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") +} + +func TestFileDetector_DetectsNodePM_Pnpm(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "pnpm-lock.yaml", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "pnpm", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Yarn(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "yarn.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "yarn", result.PackageManager) +} + +func TestFileDetector_DetectsNodePM_Bun(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{}`) + writeDetectFile(t, dir, "bun.lock", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "bun", result.PackageManager) +} + +func TestFileDetector_DetectsJsClientFramework(t *testing.T) { + tests := []struct { + dep string + framework string + }{ + {"vue", "Vue"}, + {"svelte", "Svelte"}, + {"backbone", "Backbone"}, + {"@angular/core", "Angular"}, + {"ember-source", "Ember"}, + {"preact", "Preact"}, + } + for _, tt := range tests { + t.Run(tt.framework, func(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"`+tt.dep+`":"^1.0.0"}}`) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "js-client-sdk", result.SDKID) + assert.Equal(t, tt.framework, result.Framework) + }) + } +} + +func TestFileDetector_DetectsSwift_PackageSwift(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Package.swift", "// swift-tools-version:5.9") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) + assert.Equal(t, "spm", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_Podfile(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "Podfile", "platform :ios, '14.0'") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "cocoapods", result.PackageManager) +} + +func TestFileDetector_DetectsSwift_XcodeProj(t *testing.T) { + dir := t.TempDir() + // .xcodeproj is a directory in practice, but we use Glob so creating the dir is enough + require.NoError(t, os.MkdirAll(filepath.Join(dir, "MyApp.xcodeproj"), 0755)) + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "swift-client-sdk", result.SDKID) + assert.Equal(t, "Swift", result.Language) +} + +func TestFileDetector_DetectsDotnet_Csproj(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.csproj", "") + writeDetectFile(t, dir, "Program.cs", "// entry") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "C#", result.Language) + assert.Equal(t, "dotnet", result.PackageManager) + assert.Equal(t, filepath.Join(dir, "Program.cs"), result.EntryPoint) +} + +func TestFileDetector_DetectsDotnet_Sln(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "MyApp.sln", "") + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + assert.Equal(t, "dotnet-server-sdk", result.SDKID) + assert.Equal(t, "dotnet", result.PackageManager) +} + +func TestKnownSDKs_ContainsExpectedSDKs(t *testing.T) { + ids := make([]string, len(KnownSDKs)) + for i, sdk := range KnownSDKs { + ids[i] = sdk.ID + } + assert.Contains(t, ids, "node-server") + assert.Contains(t, ids, "react-client-sdk") + assert.Contains(t, ids, "react-native") + assert.Contains(t, ids, "python-server-sdk") + assert.Contains(t, ids, "go-server-sdk") + assert.Contains(t, ids, "java-server-sdk") + assert.Contains(t, ids, "dotnet-server-sdk") + assert.Contains(t, ids, "swift-client-sdk") + assert.Contains(t, ids, "ruby-server-sdk") +} + +func TestFileDetector_EntryPointFallback_WhenNoneExist(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `{"dependencies":{"react":"^18.0.0"}}`) + // No src/App.tsx or other entry point files + + result, err := FileDetector{}.Detect(dir) + + require.NoError(t, err) + // Falls back to last candidate + assert.NotEmpty(t, result.EntryPoint) +} + +func TestFileDetector_MalformedPackageJSON_FallsThrough(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "package.json", `not valid json {{{`) + // No other project indicators + + _, err := FileDetector{}.Detect(dir) + + // detectNode skips invalid JSON; no other indicators → error + require.Error(t, err) + assert.Contains(t, err.Error(), "could not detect") +} + +func TestFirstExistingIn_EmptySlice_ReturnsEmpty(t *testing.T) { + result := firstExistingIn(t.TempDir(), []string{}) + assert.Empty(t, result) +} + +func TestFirstExistingIn_NoMatch_ReturnLastCandidate(t *testing.T) { + dir := t.TempDir() + result := firstExistingIn(dir, []string{"nonexistent.go", "also-nonexistent.go"}) + assert.Equal(t, "also-nonexistent.go", result) +} + +func TestFirstExistingIn_MatchesFirst(t *testing.T) { + dir := t.TempDir() + writeDetectFile(t, dir, "second.go", "") + writeDetectFile(t, dir, "first.go", "") + result := firstExistingIn(dir, []string{"first.go", "second.go"}) + assert.Equal(t, "first.go", result) +}