diff --git a/blast_radius_fixes_test.go b/blast_radius_fixes_test.go index 2ac559e..f2874b2 100644 --- a/blast_radius_fixes_test.go +++ b/blast_radius_fixes_test.go @@ -271,6 +271,16 @@ func TestBuildImportersReportFromGraphCarriesCoverage(t *testing.T) { } } +func TestCapBlastRadiusImportersReportCarriesCoverage(t *testing.T) { + capped := capBlastRadiusImportersReport(scanner.ImportersReport{ + CoverageStatus: "partial", + CoverageNotes: []string{"dynamic routes unresolved"}, + }, 1) + if capped.CoverageStatus != "partial" || !reflect.DeepEqual(capped.CoverageNotes, []string{"dynamic routes unresolved"}) { + t.Fatalf("capped coverage = %q %#v", capped.CoverageStatus, capped.CoverageNotes) + } +} + // Finding #9: changed files with no importers must not emit empty importer // sections. func TestBlastRadiusOmitsEmptyImporterSections(t *testing.T) { diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 96da57e..2514ff5 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -2,6 +2,7 @@ package scanner import ( "bufio" + "context" "encoding/json" "os" "path/filepath" @@ -50,12 +51,27 @@ func BuildFileGraphWithFilters(root string, filters Filters) (*FileGraph, error) func BuildFileGraphFromAnalyses(root string, analyses []FileAnalysis) (*FileGraph, error) { cfg := config.Load(root) filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude} - return BuildFileGraphFromFilteredAnalyses(root, filterAnalyses(analyses, filters), filters) + return buildFileGraphFromFilteredAnalysesWithCargoMetadata(context.Background(), root, filterAnalyses(analyses, filters), filters, loadCargoMetadata) } // BuildFileGraphFromFilteredAnalyses builds a file graph from analyses that // already match the supplied filters. func BuildFileGraphFromFilteredAnalyses(root string, analyses []FileAnalysis, filters Filters) (*FileGraph, error) { + return buildFileGraphFromFilteredAnalysesWithCargoMetadata(context.Background(), root, analyses, filters, loadCargoMetadata) +} + +// buildFileGraphFromAnalysesWithCargoMetadata is the testable configuration +// aware variant of BuildFileGraphFromAnalyses. +func buildFileGraphFromAnalysesWithCargoMetadata(ctx context.Context, root string, analyses []FileAnalysis, loader cargoMetadataLoader) (*FileGraph, error) { + cfg := config.Load(root) + filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude} + return buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx, root, filterAnalyses(analyses, filters), filters, loader) +} + +func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, root string, analyses []FileAnalysis, filters Filters, loader cargoMetadataLoader) (*FileGraph, error) { + if err := ctx.Err(); err != nil { + return nil, err + } absRoot, err := filepath.Abs(root) if err != nil { return nil, err @@ -74,7 +90,6 @@ func BuildFileGraphFromFilteredAnalyses(root string, analyses []FileAnalysis, fi // Detect path aliases from tsconfig.json (for TS/JS import resolution) fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot) - rustWorkspace := buildRustWorkspaceIndex(absRoot) // Scan all files with the same filters used for the analyses. gitCache := NewGitIgnoreCache(root) @@ -82,6 +97,10 @@ func BuildFileGraphFromFilteredAnalyses(root string, analyses []FileAnalysis, fi if err != nil { return nil, err } + rustWorkspace := buildRustWorkspaceIndex(ctx, absRoot, analyses, files, loader) + if err := ctx.Err(); err != nil { + return nil, err + } // Build file index for fast fuzzy matching idx := buildFileIndex(files, fg.Module) diff --git a/scanner/rustcargo.go b/scanner/rustcargo.go new file mode 100644 index 0000000..081bd97 --- /dev/null +++ b/scanner/rustcargo.go @@ -0,0 +1,273 @@ +package scanner + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +const cargoMetadataTimeout = 3 * time.Second + +type cargoMetadataLoader func(context.Context, string) ([]byte, error) + +type cargoMetadata struct { + Packages []cargoMetadataPackage `json:"packages"` +} + +type cargoMetadataPackage struct { + Name string `json:"name"` + ManifestPath string `json:"manifest_path"` + Targets []cargoMetadataTarget `json:"targets"` + Dependencies []cargoMetadataDependency `json:"dependencies"` +} + +type cargoMetadataTarget struct { + Name string `json:"name"` + Kind []string `json:"kind"` + SrcPath string `json:"src_path"` +} + +type cargoMetadataDependency struct { + Name string `json:"name"` + Rename string `json:"rename"` + Path string `json:"path"` + Kind string `json:"kind"` +} + +type pendingRustDependency struct { + packageRoot string + rename string + kind string +} + +func loadCargoMetadata(ctx context.Context, manifestPath string) ([]byte, error) { + cargo, err := exec.LookPath("cargo") + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, cargoMetadataTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, cargo, cargoMetadataArgs(manifestPath)...) + cmd.Dir = filepath.Dir(manifestPath) + output, err := cmd.Output() + if ctx.Err() != nil { + return nil, ctx.Err() + } + return output, err +} + +func cargoMetadataArgs(manifestPath string) []string { + return []string{ + "metadata", "--no-deps", "--format-version", "1", "--offline", + "--manifest-path", manifestPath, + } +} + +func parseCargoMetadata(data []byte) (cargoMetadata, error) { + var metadata cargoMetadata + err := json.Unmarshal(data, &metadata) + return metadata, err +} + +func buildRustWorkspaceIndex(ctx context.Context, root string, analyses []FileAnalysis, files []FileInfo, loader cargoMetadataLoader) *rustWorkspaceIndex { + index := buildRustFallbackWorkspaceIndex(root, analyses) + if loader == nil { + return index + } + ctx, cancel := context.WithTimeout(ctx, cargoMetadataTimeout) + defer cancel() + + packagesByRoot := make(map[string]rustPackage, len(index.packages)) + for _, pkg := range index.packages { + packagesByRoot[pkg.root] = pkg + } + pendingByRoot := make(map[string][]pendingRustDependency) + handledManifests := make(map[string]bool) + + for _, manifestPath := range discoverCargoManifests(root, files) { + manifestPath = filepath.Clean(manifestPath) + if handledManifests[manifestPath] { + continue + } + data, err := loader(ctx, manifestPath) + if err != nil { + continue + } + metadata, err := parseCargoMetadata(data) + if err != nil { + continue + } + + handledManifests[manifestPath] = true + for _, metadataPackage := range metadata.Packages { + pkg, pending, ok := rustPackageFromCargoMetadata(root, metadataPackage) + if !ok { + continue + } + packagesByRoot[pkg.root] = pkg + pendingByRoot[pkg.root] = pending + handledManifests[filepath.Clean(metadataPackage.ManifestPath)] = true + } + } + + for rootPath, pending := range pendingByRoot { + pkg, ok := packagesByRoot[rootPath] + if !ok { + continue + } + for _, dependency := range pending { + target, ok := packagesByRoot[dependency.packageRoot] + if !ok || target.lib == nil { + continue + } + alias := dependency.rename + if alias == "" { + alias = target.crateID + } + pkg.dependencies = append(pkg.dependencies, rustLocalDependency{ + packageRoot: dependency.packageRoot, + alias: normalizeRustCrateID(alias), + kind: dependency.kind, + }) + } + packagesByRoot[rootPath] = pkg + } + + index.packages = index.packages[:0] + index.byCrateID = make(map[string][]rustPackage) + index.byRoot = make(map[string]rustPackage) + for _, pkg := range packagesByRoot { + index.packages = append(index.packages, pkg) + index.byCrateID[pkg.crateID] = append(index.byCrateID[pkg.crateID], pkg) + index.byRoot[pkg.root] = pkg + } + sort.Slice(index.packages, func(i, j int) bool { + if len(index.packages[i].root) == len(index.packages[j].root) { + return index.packages[i].root < index.packages[j].root + } + return len(index.packages[i].root) > len(index.packages[j].root) + }) + return index +} + +func discoverCargoManifests(root string, files []FileInfo) []string { + root = filepath.Clean(root) + seen := make(map[string]bool) + for _, file := range files { + if !strings.EqualFold(filepath.Ext(file.Path), ".rs") { + continue + } + path := filepath.Clean(filepath.Join(root, file.Path)) + if _, ok := projectRelativePath(root, path); !ok { + continue + } + for dir := filepath.Dir(path); ; dir = filepath.Dir(dir) { + manifestPath := filepath.Join(dir, "Cargo.toml") + if info, err := os.Stat(manifestPath); err == nil && !info.IsDir() { + seen[filepath.Clean(manifestPath)] = true + } + if dir == root { + break + } + parent := filepath.Dir(dir) + if parent == dir || !pathWithin(parent, root) { + break + } + } + } + manifests := make([]string, 0, len(seen)) + for path := range seen { + manifests = append(manifests, path) + } + sort.Slice(manifests, func(i, j int) bool { + left, _ := filepath.Rel(root, manifests[i]) + right, _ := filepath.Rel(root, manifests[j]) + if pathDepth(left) == pathDepth(right) { + return manifests[i] < manifests[j] + } + return pathDepth(left) < pathDepth(right) + }) + return manifests +} + +func rustPackageFromCargoMetadata(root string, metadata cargoMetadataPackage) (rustPackage, []pendingRustDependency, bool) { + manifestPath, ok := projectRelativePath(root, metadata.ManifestPath) + if !ok || filepath.Base(manifestPath) != "Cargo.toml" { + return rustPackage{}, nil, false + } + pkg := rustPackage{ + crateID: normalizeRustCrateID(metadata.Name), + root: filepath.Dir(manifestPath), + authoritative: true, + } + if pkg.root == "" { + pkg.root = "." + } + for _, metadataTarget := range metadata.Targets { + rootFile, ok := projectRelativePath(root, metadataTarget.SrcPath) + if !ok { + continue + } + kind := rustCargoTargetKind(metadataTarget.Kind) + if kind == "" { + continue + } + target := rustTarget{rootFile: rootFile, sourceDir: filepath.Dir(rootFile), kind: kind} + pkg.targets = append(pkg.targets, target) + if kind == rustTargetLib && pkg.lib == nil { + lib := target + pkg.lib = &lib + pkg.crateID = normalizeRustCrateID(metadataTarget.Name) + } + } + sort.Slice(pkg.targets, func(i, j int) bool { return pkg.targets[i].rootFile < pkg.targets[j].rootFile }) + + var pending []pendingRustDependency + for _, dependency := range metadata.Dependencies { + if dependency.Path == "" { + continue + } + packageRoot, ok := projectRelativePath(root, dependency.Path) + if !ok { + continue + } + pending = append(pending, pendingRustDependency{ + packageRoot: packageRoot, + rename: dependency.Rename, + kind: dependency.Kind, + }) + } + return pkg, pending, true +} + +func rustCargoTargetKind(kinds []string) string { + for _, kind := range kinds { + switch kind { + case "lib", "proc-macro": + return rustTargetLib + case rustTargetBin, rustTargetExample, rustTargetTest, rustTargetBench, rustTargetCustomBuild: + return kind + } + } + return "" +} + +func normalizeRustCrateID(name string) string { + return strings.ReplaceAll(name, "-", "_") +} + +func projectRelativePath(root, path string) (string, bool) { + if !filepath.IsAbs(path) { + path = filepath.Join(root, path) + } + rel, err := filepath.Rel(root, filepath.Clean(path)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return filepath.Clean(rel), true +} diff --git a/scanner/rustcargo_test.go b/scanner/rustcargo_test.go new file mode 100644 index 0000000..a8b1bc1 --- /dev/null +++ b/scanner/rustcargo_test.go @@ -0,0 +1,546 @@ +package scanner + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + "time" +) + +func writeRustCargoFixture(t *testing.T, root string, files map[string]string) { + t.Helper() + for path, content := range files { + full := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func cargoTestManifest(name string) string { + return "[package]\nname = \"" + name + "\"\nversion = \"0.1.0\"\n" +} + +func cargoMetadataJSON(t *testing.T, root string, packages []map[string]any) []byte { + t.Helper() + payload, err := json.Marshal(map[string]any{ + "packages": packages, + "workspace_members": []string{}, + "workspace_root": root, + "resolve": nil, + }) + if err != nil { + t.Fatal(err) + } + return payload +} + +func cargoPackage(root, rel, name, libName string, dependencies []map[string]any) map[string]any { + return cargoPackageWithTargets(root, rel, name, []map[string]any{cargoTargetJSON(root, filepath.Join(rel, "src", "lib.rs"), libName, rustTargetLib)}, dependencies) +} + +func cargoPackageWithTargets(root, rel, name string, targets, dependencies []map[string]any) map[string]any { + manifest := filepath.Join(root, filepath.FromSlash(rel), "Cargo.toml") + return map[string]any{ + "id": "path+file://" + filepath.ToSlash(filepath.Dir(manifest)) + "#0.1.0", + "name": name, + "manifest_path": manifest, + "targets": targets, + "dependencies": dependencies, + } +} + +func cargoTargetJSON(root, rel, name, kind string) map[string]any { + return map[string]any{ + "name": name, + "kind": []string{kind}, + "src_path": filepath.Join(root, filepath.FromSlash(rel)), + } +} + +type cargoMetadataResponse struct { + data []byte + err error +} + +func scriptedCargoMetadataLoader(responses map[string]cargoMetadataResponse, calls *[]string) cargoMetadataLoader { + return func(_ context.Context, manifestPath string) ([]byte, error) { + manifestPath = filepath.Clean(manifestPath) + *calls = append(*calls, manifestPath) + response, ok := responses[manifestPath] + if !ok { + return nil, errors.New("unexpected manifest: " + manifestPath) + } + return response.data, response.err + } +} + +func TestCargoMetadataArgsAreOfflineAndTopologyOnly(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "Cargo.toml") + got := cargoMetadataArgs(manifestPath) + want := []string{ + "metadata", "--no-deps", "--format-version", "1", "--offline", + "--manifest-path", manifestPath, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("cargo metadata args = %#v, want %#v", got, want) + } +} + +func TestCargoMetadataSharesOneScanDeadline(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "one/Cargo.toml": cargoTestManifest("one"), + "one/src/lib.rs": "", + "two/Cargo.toml": cargoTestManifest("two"), + "two/src/lib.rs": "", + }) + files := []FileInfo{{Path: "one/src/lib.rs"}, {Path: "two/src/lib.rs"}} + var deadlines []time.Time + buildRustWorkspaceIndex(context.Background(), root, nil, files, func(ctx context.Context, _ string) ([]byte, error) { + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("metadata loader context has no deadline") + } + deadlines = append(deadlines, deadline) + return nil, errors.New("metadata unavailable") + }) + if len(deadlines) != 2 || !deadlines[0].Equal(deadlines[1]) { + t.Fatalf("metadata deadlines = %v, want one shared deadline", deadlines) + } +} + +func TestCargoMetadataRecoversLocalCargoTopology(t *testing.T) { + tests := []struct { + name string + workspacePath string + appPath string + corePath string + dependency map[string]any + coreName string + coreLibName string + callPath string + standalone bool + }{ + { + name: "implicit workspace path member", workspacePath: "Cargo.toml", appPath: "app", corePath: "helper", + dependency: map[string]any{"name": "helper", "rename": nil, "kind": nil}, coreName: "helper", coreLibName: "helper", callPath: "helper::api::run", + }, + { + name: "nested workspace", workspacePath: "rust/Cargo.toml", appPath: "rust/app", corePath: "rust/core", + dependency: map[string]any{"name": "core-lib", "rename": nil, "kind": nil}, coreName: "core-lib", coreLibName: "core_lib", callPath: "core_lib::api::run", + }, + { + name: "renamed dependency", workspacePath: "Cargo.toml", appPath: "app", corePath: "core", + dependency: map[string]any{"name": "core-lib", "rename": "facade", "kind": nil}, coreName: "core-lib", coreLibName: "core_lib", callPath: "facade::api::run", + }, + { + name: "custom library name", workspacePath: "Cargo.toml", appPath: "app", corePath: "core", + dependency: map[string]any{"name": "core-package", "rename": nil, "kind": nil}, coreName: "core-package", coreLibName: "engine", callPath: "engine::api::run", + }, + { + name: "standalone local path dependency", workspacePath: "Cargo.toml", appPath: ".", corePath: "helper", + dependency: map[string]any{"name": "helper", "rename": nil, "kind": nil}, coreName: "helper", coreLibName: "helper", callPath: "helper::api::run", standalone: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + appLib := filepath.ToSlash(filepath.Join(tt.appPath, "src", "lib.rs")) + coreLib := filepath.ToSlash(filepath.Join(tt.corePath, "src", "lib.rs")) + coreAPI := filepath.ToSlash(filepath.Join(tt.corePath, "src", "api.rs")) + workspaceMember, err := filepath.Rel(filepath.Dir(tt.workspacePath), tt.appPath) + if err != nil { + t.Fatal(err) + } + workspaceFixtureManifest := "[workspace]\n" + if !tt.standalone { + workspaceFixtureManifest += "members = [\"" + filepath.ToSlash(workspaceMember) + "\"]\n" + } + files := map[string]string{ + tt.workspacePath: workspaceFixtureManifest, + filepath.ToSlash(filepath.Join(tt.appPath, "Cargo.toml")): cargoTestManifest("app"), + appLib: "pub fn call() {}\n", + filepath.ToSlash(filepath.Join(tt.corePath, "Cargo.toml")): cargoTestManifest(tt.coreName), + coreLib: "pub mod api;\n", + coreAPI: "pub fn run() {}\n", + } + writeRustCargoFixture(t, root, files) + + dependency := map[string]any{} + for key, value := range tt.dependency { + dependency[key] = value + } + dependency["path"] = filepath.Join(root, filepath.FromSlash(tt.corePath)) + appPackage := cargoPackage(root, tt.appPath, "app", "app", []map[string]any{dependency}) + corePackage := cargoPackage(root, tt.corePath, tt.coreName, tt.coreLibName, nil) + responses := map[string]cargoMetadataResponse{} + workspaceManifest := filepath.Join(root, filepath.FromSlash(tt.workspacePath)) + if tt.standalone { + responses[workspaceManifest] = cargoMetadataResponse{data: cargoMetadataJSON(t, root, []map[string]any{appPackage})} + responses[filepath.Join(root, filepath.FromSlash(tt.corePath), "Cargo.toml")] = cargoMetadataResponse{data: cargoMetadataJSON(t, filepath.Join(root, filepath.FromSlash(tt.corePath)), []map[string]any{corePackage})} + } else { + responses[workspaceManifest] = cargoMetadataResponse{data: cargoMetadataJSON(t, filepath.Dir(workspaceManifest), []map[string]any{appPackage, corePackage})} + } + var calls []string + analyses := []FileAnalysis{ + {Path: appLib, Language: "rust", References: []ImportReference{{Path: tt.callPath, Kind: "rust-path"}}}, + {Path: coreLib, Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, scriptedCargoMetadataLoader(responses, &calls)) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports[appLib], []string{coreAPI}; !reflect.DeepEqual(got, want) { + t.Fatalf("imports = %#v, want %#v; metadata calls = %#v", got, want, calls) + } + wantCalls := 1 + if tt.standalone { + wantCalls = 2 + } + if len(calls) != wantCalls { + t.Fatalf("metadata calls = %#v, want %d", calls, wantCalls) + } + }) + } +} + +func TestCargoMetadataScopesDependenciesByCallerAndTargetKind(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "Cargo.toml": "[workspace]\nmembers = [\"app\"]\n", + "app/Cargo.toml": cargoTestManifest("app"), + "app/src/lib.rs": "pub fn call() {}\n", + "app/build.rs": "fn main() {}\n", + "app/tests/integration.rs": "fn test() {}\n", + "normal/Cargo.toml": "[package]\nname = \"normal\"\nversion = \"0.1.0\"\n", + "normal/src/lib.rs": "pub mod api;\n", + "normal/src/api.rs": "pub fn run() {}\n", + "build-dep/Cargo.toml": "[package]\nname = \"build-dep\"\nversion = \"0.1.0\"\n", + "build-dep/src/lib.rs": "pub mod api;\n", + "build-dep/src/api.rs": "pub fn run() {}\n", + "dev-dep/Cargo.toml": "[package]\nname = \"dev-dep\"\nversion = \"0.1.0\"\n", + "dev-dep/src/lib.rs": "pub mod api;\n", + "dev-dep/src/api.rs": "pub fn run() {}\n", + } + writeRustCargoFixture(t, root, files) + dependencies := []map[string]any{ + {"name": "normal", "rename": nil, "path": filepath.Join(root, "normal"), "kind": nil}, + {"name": "build-dep", "rename": "build_dep", "path": filepath.Join(root, "build-dep"), "kind": "build"}, + {"name": "dev-dep", "rename": "dev_dep", "path": filepath.Join(root, "dev-dep"), "kind": "dev"}, + } + packages := []map[string]any{ + cargoPackageWithTargets(root, "app", "app", []map[string]any{ + cargoTargetJSON(root, "app/src/lib.rs", "app", rustTargetLib), + cargoTargetJSON(root, "app/build.rs", "build-script-build", rustTargetCustomBuild), + cargoTargetJSON(root, "app/tests/integration.rs", "integration", rustTargetTest), + }, dependencies), + cargoPackage(root, "normal", "normal", "normal", nil), + cargoPackage(root, "build-dep", "build-dep", "build_dep", nil), + cargoPackage(root, "dev-dep", "dev-dep", "dev_dep", nil), + } + metadata := cargoMetadataJSON(t, root, packages) + refs := []ImportReference{ + {Path: "normal::api::run", Kind: "rust-path"}, + {Path: "build_dep::api::run", Kind: "rust-path"}, + {Path: "dev_dep::api::run", Kind: "rust-path"}, + } + analyses := []FileAnalysis{ + {Path: "app/src/lib.rs", Language: "rust", References: refs}, + {Path: "app/build.rs", Language: "rust", References: refs}, + {Path: "app/tests/integration.rs", Language: "rust", References: refs}, + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { return metadata, nil }) + if err != nil { + t.Fatal(err) + } + for from, want := range map[string][]string{ + "app/src/lib.rs": {"normal/src/api.rs"}, + "app/build.rs": {"build-dep/src/api.rs"}, + "app/tests/integration.rs": {"dev-dep/src/api.rs", "normal/src/api.rs"}, + } { + got := append([]string(nil), graph.Imports[from]...) + sort.Strings(got) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s imports = %#v, want %#v", from, got, want) + } + } +} + +func TestCargoMetadataRejectsUndeclaredWorkspaceCrateCollision(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[workspace]\nmembers = [\"app\", \"helper-crate\"]\n", + "app/Cargo.toml": cargoTestManifest("app"), + "app/src/lib.rs": "mod helper;\n", + "app/src/helper.rs": "pub mod api;\n", + "app/src/helper/api.rs": "pub fn run() {}\n", + "helper-crate/Cargo.toml": "[package]\nname = \"helper\"\nversion = \"0.1.0\"\n", + "helper-crate/src/lib.rs": "pub mod api;\n", + "helper-crate/src/api.rs": "pub fn run() {}\n", + }) + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackage(root, "app", "app", "app", nil), + cargoPackage(root, "helper-crate", "helper", "helper", nil), + }) + analyses := []FileAnalysis{ + {Path: "app/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "helper", Kind: "rust-module"}, {Path: "helper::api::run", Kind: "rust-path"}}}, + {Path: "app/src/helper.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + {Path: "helper-crate/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { return metadata, nil }) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports["app/src/lib.rs"], []string{"app/src/helper.rs", "app/src/helper/api.rs"}; !reflect.DeepEqual(got, want) { + t.Fatalf("app imports = %#v, want only local module %#v", got, want) + } + if got, want := graph.Importers["helper-crate/src/api.rs"], []string{"helper-crate/src/lib.rs"}; !reflect.DeepEqual(got, want) { + t.Fatalf("unrelated crate importers = %#v, want %#v", got, want) + } +} + +func TestCargoMetadataPrefersDeclaredModuleOverDependency(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[workspace]\nmembers = [\"app\", \"helper-crate\"]\n", + "app/Cargo.toml": cargoTestManifest("app"), + "app/src/lib.rs": "mod helper;\npub fn call() { helper::api::run(); }\n", + "app/src/helper.rs": "pub mod api;\n", + "app/src/helper/api.rs": "pub fn run() {}\n", + "helper-crate/Cargo.toml": cargoTestManifest("helper"), + "helper-crate/src/lib.rs": "pub mod api;\n", + "helper-crate/src/api.rs": "pub fn run() {}\n", + }) + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackage(root, "app", "app", "app", []map[string]any{{ + "name": "helper", "path": filepath.Join(root, "helper-crate"), "kind": nil, + }}), + cargoPackage(root, "helper-crate", "helper", "helper", nil), + }) + analyses := []FileAnalysis{ + {Path: "app/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "helper", Kind: "rust-module"}, {Path: "helper::api::run", Kind: "rust-path"}}}, + {Path: "app/src/helper.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + {Path: "helper-crate/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { return metadata, nil }) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports["app/src/lib.rs"], []string{"app/src/helper.rs", "app/src/helper/api.rs"}; !reflect.DeepEqual(got, want) { + t.Fatalf("app imports = %#v, want local module only %#v", got, want) + } +} + +func TestCargoMetadataRejectsModulesFromUnownedSourceFiles(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\nautolib = false\n", + "src/lib.rs": "mod common;\n", + "src/common.rs": "pub fn run() {}\n", + "tools/app.rs": "fn main() {}\n", + }) + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackageWithTargets(root, ".", "app", []map[string]any{ + cargoTargetJSON(root, "tools/app.rs", "app", rustTargetBin), + }, nil), + }) + analyses := []FileAnalysis{{ + Path: "src/lib.rs", Language: "rust", + References: []ImportReference{{Path: "common", Kind: "rust-module"}}, + }} + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { + return metadata, nil + }) + if err != nil { + t.Fatal(err) + } + if got := graph.Imports["src/lib.rs"]; len(got) != 0 { + t.Fatalf("unowned source imports = %#v, want none", got) + } +} + +func TestCargoMetadataRejectsAmbiguousDependencyAlias(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[workspace]\nmembers = [\"app\", \"first\", \"second\"]\n", + "app/Cargo.toml": cargoTestManifest("app"), + "app/src/lib.rs": "pub fn call() {}\n", + "first/Cargo.toml": "[package]\nname = \"first\"\nversion = \"0.1.0\"\n", + "first/src/lib.rs": "pub mod api;\n", + "first/src/api.rs": "pub fn run() {}\n", + "second/Cargo.toml": "[package]\nname = \"second\"\nversion = \"0.1.0\"\n", + "second/src/lib.rs": "pub mod api;\n", + "second/src/api.rs": "pub fn run() {}\n", + }) + metadata := cargoMetadataJSON(t, root, []map[string]any{ + // Cargo rejects duplicate alias sources, so the second declaration models malformed or untrusted metadata. + cargoPackage(root, "app", "app", "app", []map[string]any{ + {"name": "first", "rename": "shared", "path": filepath.Join(root, "first"), "kind": nil}, + {"name": "second", "rename": "shared", "path": filepath.Join(root, "second"), "kind": nil}, + }), + cargoPackage(root, "first", "first", "first", nil), + cargoPackage(root, "second", "second", "second", nil), + }) + analyses := []FileAnalysis{ + {Path: "app/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "shared::api::run", Kind: "rust-path"}}}, + {Path: "first/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + {Path: "second/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { return metadata, nil }) + if err != nil { + t.Fatal(err) + } + if got := graph.Imports["app/src/lib.rs"]; len(got) != 0 { + t.Fatalf("ambiguous alias imports = %#v, want none", got) + } +} + +func TestCargoMetadataKeepsSuccessfulRootsWhenAnotherRootFallsBack(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"fallback\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "mod helper;\n", + "src/helper.rs": "pub fn run() {}\n", + "metadata/Cargo.toml": "[workspace]\nmembers = [\"app\"]\n", + "metadata/app/Cargo.toml": cargoTestManifest("app"), + "metadata/app/src/lib.rs": "pub fn call() {}\n", + "metadata/core/Cargo.toml": "[package]\nname = \"core-package\"\nversion = \"0.1.0\"\n", + "metadata/core/src/lib.rs": "pub mod api;\n", + "metadata/core/src/api.rs": "pub fn run() {}\n", + }) + metadataRoot := filepath.Join(root, "metadata") + metadata := cargoMetadataJSON(t, metadataRoot, []map[string]any{ + cargoPackage(root, "metadata/app", "app", "app", []map[string]any{{ + "name": "core-package", "rename": nil, "path": filepath.Join(metadataRoot, "core"), "kind": nil, + }}), + cargoPackage(root, "metadata/core", "core-package", "engine", nil), + }) + responses := map[string]cargoMetadataResponse{ + filepath.Join(metadataRoot, "Cargo.toml"): {data: metadata}, + filepath.Join(root, "Cargo.toml"): {err: errors.New("cargo unavailable")}, + } + var calls []string + analyses := []FileAnalysis{ + {Path: "metadata/app/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "engine::api::run", Kind: "rust-path"}}}, + {Path: "metadata/core/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}, + {Path: "src/lib.rs", Language: "rust", References: []ImportReference{{Path: "helper", Kind: "rust-module"}}}, + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, scriptedCargoMetadataLoader(responses, &calls)) + if err != nil { + t.Fatal(err) + } + for from, want := range map[string][]string{ + "metadata/app/src/lib.rs": {"metadata/core/src/api.rs"}, + "src/lib.rs": {"src/helper.rs"}, + } { + if got := graph.Imports[from]; !reflect.DeepEqual(got, want) { + t.Errorf("%s imports = %#v, want %#v", from, got, want) + } + } + if len(calls) != 2 { + t.Fatalf("metadata calls = %#v, want one per independent root", calls) + } +} + +func TestCargoMetadataFailuresPreserveManualFallback(t *testing.T) { + for _, tt := range []struct { + name string + data []byte + err error + }{ + {name: "cargo unavailable", err: errors.New("cargo not found")}, + {name: "metadata timeout", err: context.DeadlineExceeded}, + {name: "malformed metadata", data: []byte("{")}, + } { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"fallback\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "mod api;\n", + "src/api.rs": "pub fn run() {}\n", + }) + analyses := []FileAnalysis{{Path: "src/lib.rs", Language: "rust", References: []ImportReference{{Path: "api", Kind: "rust-module"}}}} + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { return tt.data, tt.err }) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports["src/lib.rs"], []string{"src/api.rs"}; !reflect.DeepEqual(got, want) { + t.Fatalf("fallback imports = %#v, want %#v", got, want) + } + }) + } +} + +func TestCargoMetadataFailurePreservesLegacyBinaryRoot(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + "src/main.rs": "mod helper;\n", + "src/helper.rs": "pub fn run() {}\n", + }) + analyses := []FileAnalysis{{ + Path: "src/main.rs", Language: "rust", + References: []ImportReference{{Path: "helper", Kind: "rust-module"}}, + }} + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { + return nil, errors.New("cargo unavailable") + }) + if err != nil { + t.Fatal(err) + } + if got, want := graph.Imports["src/main.rs"], []string{"src/helper.rs"}; !reflect.DeepEqual(got, want) { + t.Fatalf("fallback binary imports = %#v, want %#v", got, want) + } +} + +func TestCargoMetadataHonorsCallerCancellation(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "pub fn call() {}\n", + }) + ctx, cancel := context.WithCancel(context.Background()) + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(ctx, root, []FileAnalysis{{ + Path: "src/lib.rs", Language: "rust", + }}, func(ctx context.Context, _ string) ([]byte, error) { + cancel() + return nil, ctx.Err() + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } + if graph != nil { + t.Fatalf("graph = %#v, want nil after cancellation", graph) + } +} + +func TestCargoMetadataRejectsPathsOutsideProject(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "pub fn call() {}\n", + }) + metadata := cargoMetadataJSON(t, root, []map[string]any{ + cargoPackage(root, ".", "app", "app", []map[string]any{{"name": "outside", "rename": nil, "path": outside, "kind": nil}}), + cargoPackage(outside, ".", "outside", "outside", nil), + }) + analyses := []FileAnalysis{{Path: "src/lib.rs", Language: "rust", References: []ImportReference{{Path: "outside::api::run", Kind: "rust-path"}}}} + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, func(context.Context, string) ([]byte, error) { return metadata, nil }) + if err != nil { + t.Fatal(err) + } + if got := graph.Imports["src/lib.rs"]; len(got) != 0 { + t.Fatalf("outside-root imports = %#v, want none", got) + } +} diff --git a/scanner/rustgraph.go b/scanner/rustgraph.go index f43c32e..39d18ba 100644 --- a/scanner/rustgraph.go +++ b/scanner/rustgraph.go @@ -11,7 +11,14 @@ import ( const ( rustCoverageStatus = "partial" - rustCoverageNote = "Rust macro-generated, string-routed, renamed-dependency, and #[path] module edges may be unresolved" + rustCoverageNote = "Rust macro-generated, cfg-gated dev-dependency, string-routed, and #[path] module edges may be unresolved" + + rustTargetLib = "lib" + rustTargetBin = "bin" + rustTargetExample = "example" + rustTargetTest = "test" + rustTargetBench = "bench" + rustTargetCustomBuild = "custom-build" ) // GraphCoverage describes known dependency-graph blind spots. @@ -20,16 +27,33 @@ type GraphCoverage struct { Notes []string `json:"notes,omitempty"` } -type rustPackage struct { - crateID string - root string +type rustTarget struct { + rootFile string sourceDir string - libFile string + kind string +} + +type rustLocalDependency struct { + packageRoot string + alias string + kind string +} + +type rustPackage struct { + crateID string + root string + lib *rustTarget + targets []rustTarget + dependencies []rustLocalDependency + authoritative bool } type rustWorkspaceIndex struct { - byCrateID map[string][]rustPackage - packages []rustPackage + byCrateID map[string][]rustPackage + byRoot map[string]rustPackage + packages []rustPackage + moduleDecls map[string]map[string]bool + moduleNames map[string]map[string]bool } type cargoManifest struct { @@ -45,8 +69,34 @@ type cargoManifest struct { } `toml:"lib"` } -func buildRustWorkspaceIndex(root string) *rustWorkspaceIndex { - index := &rustWorkspaceIndex{byCrateID: make(map[string][]rustPackage)} +func buildRustFallbackWorkspaceIndex(root string, analyses []FileAnalysis) *rustWorkspaceIndex { + index := &rustWorkspaceIndex{ + byCrateID: make(map[string][]rustPackage), + byRoot: make(map[string]rustPackage), + moduleDecls: make(map[string]map[string]bool), + moduleNames: make(map[string]map[string]bool), + } + for _, analysis := range analyses { + if analysis.Language != "rust" { + continue + } + for _, ref := range analysis.References { + if ref.Kind != "rust-module" { + continue + } + if index.moduleNames[analysis.Path] == nil { + index.moduleNames[analysis.Path] = make(map[string]bool) + } + index.moduleNames[analysis.Path][ref.Path] = true + if rustModuleUsesPathAttribute(root, analysis.Path, ref.Line) { + continue + } + if index.moduleDecls[analysis.Path] == nil { + index.moduleDecls[analysis.Path] = make(map[string]bool) + } + index.moduleDecls[analysis.Path][ref.Path] = true + } + } rootManifest, ok := readCargoManifest(filepath.Join(root, "Cargo.toml")) if !ok { return index @@ -96,14 +146,16 @@ func buildRustWorkspaceIndex(root string) *rustWorkspaceIndex { libFile = filepath.Join("src", "lib.rs") } libFile = filepath.Clean(filepath.Join(memberDir, libFile)) + lib := rustTarget{rootFile: libFile, sourceDir: filepath.Dir(libFile), kind: rustTargetLib} pkg := rustPackage{ - crateID: strings.ReplaceAll(manifest.Package.Name, "-", "_"), - root: memberDir, - sourceDir: filepath.Dir(libFile), - libFile: libFile, + crateID: strings.ReplaceAll(manifest.Package.Name, "-", "_"), + root: memberDir, + lib: &lib, + targets: []rustTarget{lib}, } index.packages = append(index.packages, pkg) index.byCrateID[pkg.crateID] = append(index.byCrateID[pkg.crateID], pkg) + index.byRoot[pkg.root] = pkg } sort.Slice(index.packages, func(i, j int) bool { @@ -226,11 +278,11 @@ func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, w if rustModuleUsesPathAttribute(root, analysis.Path, ref.Line) { continue } - if target := resolveRustModule(ref.Path, analysis.Path, idx); target != "" { + if target := resolveRustModule(ref.Path, analysis.Path, idx, workspace); target != "" && target != analysis.Path { resolved = append(resolved, target) } case "rust-path": - if target := resolveRustPath(ref.Path, analysis.Path, idx, workspace); target != "" { + if target := resolveRustPath(ref.Path, analysis.Path, idx, workspace); target != "" && target != analysis.Path { resolved = append(resolved, target) } } @@ -238,8 +290,11 @@ func resolveRustReferences(root string, analysis FileAnalysis, idx *fileIndex, w return dedupe(resolved) } -func resolveRustModule(name, fromFile string, idx *fileIndex) string { - dir := rustChildModuleDir(fromFile) +func resolveRustModule(name, fromFile string, idx *fileIndex, workspace *rustWorkspaceIndex) string { + dir := rustModuleDir(fromFile, idx, workspace) + if dir == "" { + return "" + } for _, candidate := range []string{ filepath.Join(dir, name+".rs"), filepath.Join(dir, name, "mod.rs"), @@ -251,6 +306,19 @@ func resolveRustModule(name, fromFile string, idx *fileIndex) string { return "" } +func rustModuleDir(fromFile string, idx *fileIndex, workspace *rustWorkspaceIndex) string { + if pkg, ok := workspace.packageForFile(fromFile); ok && pkg.authoritative { + target, ok := workspace.targetForFile(fromFile, idx) + if !ok { + return "" + } + if target.rootFile == filepath.Clean(fromFile) { + return target.sourceDir + } + } + return rustChildModuleDir(fromFile) +} + func rustChildModuleDir(path string) string { dir := filepath.Dir(path) base := filepath.Base(path) @@ -273,29 +341,48 @@ func resolveRustPath(path, fromFile string, idx *fileIndex, workspace *rustWorks rootFile := "" switch parts[0] { case "crate": - pkg, ok := workspace.packageForFile(fromFile) + target, ok := workspace.targetForFile(fromFile, idx) if !ok { return "" } - base = pkg.sourceDir - rootFile = pkg.libFile + base = target.sourceDir + rootFile = target.rootFile parts = parts[1:] case "self": - base = rustChildModuleDir(fromFile) + base = rustModuleDir(fromFile, idx, workspace) + if base == "" { + return "" + } parts = parts[1:] case "super": - base = rustChildModuleDir(fromFile) + base = rustModuleDir(fromFile, idx, workspace) + if base == "" { + return "" + } for len(parts) > 0 && parts[0] == "super" { base = filepath.Dir(base) parts = parts[1:] } default: - packages := workspace.byCrateID[parts[0]] + if workspace.moduleNames[fromFile][parts[0]] { + if !workspace.moduleDecls[fromFile][parts[0]] { + return "" + } + base = rustModuleDir(fromFile, idx, workspace) + if base == "" { + return "" + } + break + } + packages := workspace.externalPackagesForPath(parts[0], fromFile, idx) if len(packages) != 1 { return "" } - base = packages[0].sourceDir - rootFile = packages[0].libFile + if packages[0].lib == nil || !rustTargetIndexed(*packages[0].lib, idx) { + return "" + } + base = packages[0].lib.sourceDir + rootFile = packages[0].lib.rootFile parts = parts[1:] } @@ -315,6 +402,142 @@ func resolveRustPath(path, fromFile string, idx *fileIndex, workspace *rustWorks return "" } +func (index *rustWorkspaceIndex) externalPackagesForPath(alias, fromFile string, idx *fileIndex) []rustPackage { + pkg, ok := index.packageForFile(fromFile) + if !ok || !pkg.authoritative { + return index.byCrateID[alias] + } + target, ok := index.targetForFile(fromFile, idx) + if !ok { + return nil + } + seen := make(map[string]bool) + var packages []rustPackage + for _, dependency := range pkg.dependencies { + if dependency.alias != alias || !rustDependencyEligible(dependency.kind, target.kind) || seen[dependency.packageRoot] { + continue + } + dependencyPackage, ok := index.byRoot[dependency.packageRoot] + if !ok { + continue + } + seen[dependency.packageRoot] = true + packages = append(packages, dependencyPackage) + } + return packages +} + +func rustDependencyEligible(dependencyKind, targetKind string) bool { + switch dependencyKind { + case "build": + return targetKind == rustTargetCustomBuild + case "dev": + return targetKind == rustTargetTest || targetKind == rustTargetExample || targetKind == rustTargetBench + default: + return targetKind != rustTargetCustomBuild + } +} + +func (index *rustWorkspaceIndex) targetForFile(path string, idx *fileIndex) (rustTarget, bool) { + pkg, ok := index.packageForFile(path) + if !ok { + return rustTarget{}, false + } + path = filepath.Clean(path) + for _, target := range pkg.targets { + if rustTargetIndexed(target, idx) && path == target.rootFile { + return target, true + } + } + + bestDepth := -1 + var best rustTarget + ambiguous := false + for _, target := range pkg.targets { + if !rustTargetIndexed(target, idx) || !index.targetContainsFile(target, path, idx) { + continue + } + depth := pathDepth(target.sourceDir) + switch { + case depth > bestDepth: + bestDepth = depth + best = target + ambiguous = false + case depth == bestDepth && target.rootFile != best.rootFile: + ambiguous = true + } + } + if bestDepth < 0 || ambiguous { + return rustTarget{}, false + } + return best, true +} + +func rustTargetIndexed(target rustTarget, idx *fileIndex) bool { + files := idx.byExact[target.rootFile] + return len(files) == 1 && files[0] == target.rootFile +} + +func pathWithin(path, dir string) bool { + path, dir = filepath.Clean(path), filepath.Clean(dir) + if dir == "." { + return true + } + return path == dir || strings.HasPrefix(path, dir+string(filepath.Separator)) +} + +func (index *rustWorkspaceIndex) targetContainsFile(target rustTarget, path string, idx *fileIndex) bool { + if !pathWithin(path, target.sourceDir) { + return false + } + rel, err := filepath.Rel(target.sourceDir, path) + if err != nil || rel == "." { + return err == nil + } + parts := strings.Split(rel, string(filepath.Separator)) + last := parts[len(parts)-1] + if last == "mod.rs" { + parts = parts[:len(parts)-1] + } else if filepath.Ext(last) == ".rs" { + parts[len(parts)-1] = strings.TrimSuffix(last, ".rs") + } else { + return false + } + if len(parts) == 0 { + return false + } + + parent := target.rootFile + for i, name := range parts { + if !index.moduleDecls[parent][name] { + return false + } + modulePath := filepath.Join(append([]string{target.sourceDir}, parts[:i+1]...)...) + var next string + for _, candidate := range []string{modulePath + ".rs", filepath.Join(modulePath, "mod.rs")} { + if files := idx.byExact[candidate]; len(files) == 1 { + if next != "" { + return false + } + next = files[0] + } + } + if next == "" { + return false + } + parent = next + } + return parent == path +} + +func pathDepth(path string) int { + path = filepath.Clean(path) + if path == "." { + return 0 + } + return strings.Count(path, string(filepath.Separator)) + 1 +} + func (index *rustWorkspaceIndex) packageForFile(path string) (rustPackage, bool) { path = filepath.Clean(path) var rootPackage *rustPackage