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)
+}