diff --git a/packages/turbo-repository/__tests__/find-packages.test.ts b/packages/turbo-repository/__tests__/find-packages.test.ts index 0d9c43ebd91f6..db30aa5d1a97c 100644 --- a/packages/turbo-repository/__tests__/find-packages.test.ts +++ b/packages/turbo-repository/__tests__/find-packages.test.ts @@ -3,16 +3,17 @@ import { strict as assert } from "node:assert"; import * as path from "node:path"; import { Workspace, Package } from "../js/dist/index.js"; +const MONOREPO_PATH = path.resolve(__dirname, "./fixtures/monorepo"); + describe("findPackages", () => { it("enumerates packages", async () => { - const workspace = await Workspace.find("./fixtures/monorepo"); + const workspace = await Workspace.find(MONOREPO_PATH); const packages: Package[] = await workspace.findPackages(); assert.notEqual(packages.length, 0); }); it("returns a package graph", async () => { - const dir = path.resolve(__dirname, "./fixtures/monorepo"); - const workspace = await Workspace.find(dir); + const workspace = await Workspace.find(MONOREPO_PATH); const packages = await workspace.findPackagesWithGraph(); assert.equal(Object.keys(packages).length, 2); @@ -26,4 +27,56 @@ describe("findPackages", () => { assert.deepEqual(pkg2.dependencies, []); assert.deepEqual(pkg2.dependents, ["apps/app"]); }); + + it("returns the package for a given path", async () => { + const workspace = await Workspace.find(MONOREPO_PATH); + + for (const [filePath, result] of [ + ["apps/app/src/util/useful-file.ts", "app-a"], + [ + "apps/app/src/very/deeply/nested/file/that/is/deep/as/can/be/with/a/package.ts", + "app-a", + ], + ["apps/app/src/util/non-typescript-file.txt", "app-a"], + ["apps/app/src/a-directory", "app-a"], + ["apps/app/package.json", "app-a"], + ["apps/app/tsconfig.json", "app-a"], + ["apps/app", "app-a"], // The root of a package is still "within" a package! + ["apps/app/", "app-a"], // Trailing-slash should be ignored + ["packages/ui/pretty-stuff.css", "ui"], + // This may be unintentional - I expected `findPackages` to return a nameless-package for `packages/blank` (whose + // `package.json` is missing a `name` field), but instead there is no such package returned. + ["packages/blank/nothing.null", undefined], + ["packages/not-in-a-package", undefined], + ["packages/not-in-a-package/but/very/deep/within/nothingness", undefined], + ["", undefined], + [".", undefined], + ["..", undefined], + ["apps/../apps/app/src", "app-a"], + ["apps/app/src/util/../../../../apps/app", "app-a"], + ["not a legal ^&(^) path", undefined], + ["package.json", undefined], + ["tsconfig.json", undefined], + ]) { + if (result === undefined) { + assert.rejects( + () => workspace.findPackageByPath(filePath!), + `Expected rejection for ${filePath}` + ); + } else { + workspace + .findPackageByPath(filePath!) + .then((pkg) => { + assert.equal( + pkg.name, + result, + `Expected ${result} for ${filePath}` + ); + }) + .catch((reason) => { + assert.fail(`Expected success for ${filePath}, but got ${reason}`); + }); + } + } + }); }); diff --git a/packages/turbo-repository/js/index.d.ts b/packages/turbo-repository/js/index.d.ts index 0ebf830aad724..5e90e726ece0c 100644 --- a/packages/turbo-repository/js/index.d.ts +++ b/packages/turbo-repository/js/index.d.ts @@ -60,4 +60,14 @@ export class Workspace { base?: string | undefined | null, optimizeGlobalInvalidations?: boolean | undefined | null ): Promise>; + /** + * Given a path (relative to the workspace root), returns the + * package that contains it. + * + * This is a naive implementation that simply "iterates-up". If this function is + * expected to be called many times for files that are deep within the same + * package, we could optimize this by caching the containing-package of + * every ancestor. + */ + findPackageByPath(path: string): Promise; } diff --git a/packages/turbo-repository/rust/src/lib.rs b/packages/turbo-repository/rust/src/lib.rs index 3a5dd68779411..2fc0cd789e1e8 100644 --- a/packages/turbo-repository/rust/src/lib.rs +++ b/packages/turbo-repository/rust/src/lib.rs @@ -11,7 +11,7 @@ use turbopath::{AbsoluteSystemPath, AnchoredSystemPath, AnchoredSystemPathBuf}; use turborepo_repository::{ change_mapper::{ ChangeMapper, DefaultPackageChangeMapper, DefaultPackageChangeMapperWithLockfile, - LockfileContents, PackageChanges, + LockfileContents, PackageChangeMapper, PackageChanges, }, inference::RepoState as WorkspaceState, package_graph::{PackageGraph, PackageName, PackageNode, WorkspacePackage, ROOT_PKG_NAME}, @@ -291,4 +291,39 @@ impl Workspace { Ok(serializable_packages) } + + /// Given a path (relative to the workspace root), returns the + /// package that contains it. + /// + /// This is a naive implementation that simply "iterates-up". If this + /// function is expected to be called many times for files that are deep + /// within the same package, we could optimize this by caching the + /// containing-package of every ancestor. + #[napi] + pub async fn find_package_by_path(&self, path: String) -> Result { + let package_mapper = DefaultPackageChangeMapper::new(&self.graph); + let anchored_path = AnchoredSystemPath::new(&path) + .map_err(|e| Error::from_reason(e.to_string()))? + .clean(); + match package_mapper.detect_package(&anchored_path) { + turborepo_repository::change_mapper::PackageMapping::All( + _all_package_change_reason, + ) => Err(Error::from_reason("file belongs to many packages")), + turborepo_repository::change_mapper::PackageMapping::None => Err(Error::from_reason( + "iterated to the root of the workspace and found no package", + )), + turborepo_repository::change_mapper::PackageMapping::Package((package, _reason)) => { + let workspace_root = match AbsoluteSystemPath::new(&self.absolute_path) { + Ok(path) => path, + Err(e) => return Err(Error::from_reason(e.to_string())), + }; + let package_path = workspace_root.resolve(&package.path); + Ok(Package::new( + package.name.to_string(), + workspace_root, + &package_path, + )) + } + } + } }