diff --git a/.changes/js-pnpm-catalogs.md b/.changes/js-pnpm-catalogs.md new file mode 100644 index 00000000..31c5beba --- /dev/null +++ b/.changes/js-pnpm-catalogs.md @@ -0,0 +1,5 @@ +--- +"@covector/apply": minor +--- + +`catalog:` references in package.json are left untouched during dependency bumps: pnpm rewrites them at publish time from the catalog tables in pnpm-workspace.yaml, and previously they were corrupted to a bare major version. diff --git a/.changes/range-requirement-guard.md b/.changes/range-requirement-guard.md new file mode 100644 index 00000000..aca1a04d --- /dev/null +++ b/.changes/range-requirement-guard.md @@ -0,0 +1,5 @@ +--- +"@covector/apply": patch +--- + +Leave a dependency requirement that spans a range or floats alone instead of collapsing it onto the bumped version. A comparator range (`>=1.0 <2`), a wildcard (`1.x`), and `*` already cover the bumped version, and were previously narrowed to a single pin — `">=1.0 <2"` became `"=1.1"`. This covers requirements written behind the pnpm workspace protocol prefix (`workspace:1.x`) as well as plain ones. diff --git a/.changes/rust-target-workspace-dep.md b/.changes/rust-target-workspace-dep.md new file mode 100644 index 00000000..ddcca6ef --- /dev/null +++ b/.changes/rust-target-workspace-dep.md @@ -0,0 +1,5 @@ +--- +"@covector/apply": patch +--- + +Skip dependencies declared without a version of their own in a cargo `[target]` table, such as `{ workspace = true }` or a path-only entry. Bumping a package that another crate depends on through a target table previously threw. diff --git a/.changes/rust-workspace-inherited-version-read.md b/.changes/rust-workspace-inherited-version-read.md new file mode 100644 index 00000000..e31eb772 --- /dev/null +++ b/.changes/rust-workspace-inherited-version-read.md @@ -0,0 +1,5 @@ +--- +"@covector/files": patch +--- + +Read the version off `[workspace.package]` when a Cargo manifest inherits it, matching the existing write support. Applying a bump to a crate whose version is declared at the workspace root previously left the manifest unchanged. diff --git a/.changes/rust-workspace-root-deps.md b/.changes/rust-workspace-root-deps.md new file mode 100644 index 00000000..a134b1b0 --- /dev/null +++ b/.changes/rust-workspace-root-deps.md @@ -0,0 +1,6 @@ +--- +"@covector/apply": minor +"@covector/files": minor +--- + +Bump version requirements for member crates declared in a cargo workspace root manifest's `[workspace.dependencies]` table. Requirements keep their form (partial pins stay partial, range prefixes are preserved), while path-only entries, `*` requirements, and comparator or wildcard ranges (`>=1.2, <2`, `1.*`) are left untouched. diff --git a/__fixtures__/pkg.js-pnpm-catalog/package.json b/__fixtures__/pkg.js-pnpm-catalog/package.json new file mode 100644 index 00000000..ae9704a9 --- /dev/null +++ b/__fixtures__/pkg.js-pnpm-catalog/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "name": "js-pnpm-catalog", + "description": "workspace with pnpm catalogs", + "version": "1.0.0" +} diff --git a/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-a/package.json b/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-a/package.json new file mode 100644 index 00000000..7cf5161b --- /dev/null +++ b/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-a/package.json @@ -0,0 +1,11 @@ +{ + "name": "js-catalog-pkg-a", + "version": "1.0.0", + "dependencies": { + "react": "catalog:", + "js-catalog-pkg-b": "catalog:" + }, + "devDependencies": { + "js-catalog-pkg-c": "catalog:tools" + } +} diff --git a/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-b/package.json b/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-b/package.json new file mode 100644 index 00000000..d95a0f80 --- /dev/null +++ b/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-b/package.json @@ -0,0 +1,4 @@ +{ + "name": "js-catalog-pkg-b", + "version": "1.0.0" +} diff --git a/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-c/package.json b/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-c/package.json new file mode 100644 index 00000000..696856d5 --- /dev/null +++ b/__fixtures__/pkg.js-pnpm-catalog/packages/pkg-c/package.json @@ -0,0 +1,4 @@ +{ + "name": "js-catalog-pkg-c", + "version": "1.0.0" +} diff --git a/__fixtures__/pkg.js-pnpm-catalog/pnpm-workspace.yaml b/__fixtures__/pkg.js-pnpm-catalog/pnpm-workspace.yaml new file mode 100644 index 00000000..2be5e96b --- /dev/null +++ b/__fixtures__/pkg.js-pnpm-catalog/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - "packages/*" + +# internal packages pinned here +catalog: + react: ^18.2.0 + js-catalog-pkg-b: ^1.0.0 + +catalogs: + tools: + js-catalog-pkg-c: "1.0" diff --git a/__fixtures__/pkg.js-pnpm-workspace/packages/pkg-d/package.json b/__fixtures__/pkg.js-pnpm-workspace/packages/pkg-d/package.json new file mode 100644 index 00000000..be4e9610 --- /dev/null +++ b/__fixtures__/pkg.js-pnpm-workspace/packages/pkg-d/package.json @@ -0,0 +1,10 @@ +{ + "name": "pnpm-workspace-pkg-d", + "version": "1.0.0", + "dependencies": { + "pnpm-workspace-pkg-b": "workspace:1.x" + }, + "devDependencies": { + "pnpm-workspace-pkg-c": "workspace:>=1.0 <2" + } +} diff --git a/__fixtures__/pkg.js-range-deps/package.json b/__fixtures__/pkg.js-range-deps/package.json new file mode 100644 index 00000000..ff126cba --- /dev/null +++ b/__fixtures__/pkg.js-range-deps/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "name": "js-range-deps", + "description": "workspace", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ] +} diff --git a/__fixtures__/pkg.js-range-deps/packages/pkg-a/package.json b/__fixtures__/pkg.js-range-deps/packages/pkg-a/package.json new file mode 100644 index 00000000..30577c0b --- /dev/null +++ b/__fixtures__/pkg.js-range-deps/packages/pkg-a/package.json @@ -0,0 +1,11 @@ +{ + "name": "range-deps-pkg-a", + "version": "1.0.0", + "dependencies": { + "range-deps-pkg-b": ">=1.0 <2", + "range-deps-pkg-c": "*" + }, + "devDependencies": { + "range-deps-pkg-d": "1.x" + } +} diff --git a/__fixtures__/pkg.js-range-deps/packages/pkg-b/package.json b/__fixtures__/pkg.js-range-deps/packages/pkg-b/package.json new file mode 100644 index 00000000..8827d9e9 --- /dev/null +++ b/__fixtures__/pkg.js-range-deps/packages/pkg-b/package.json @@ -0,0 +1,4 @@ +{ + "name": "range-deps-pkg-b", + "version": "1.0.0" +} diff --git a/__fixtures__/pkg.js-range-deps/packages/pkg-c/package.json b/__fixtures__/pkg.js-range-deps/packages/pkg-c/package.json new file mode 100644 index 00000000..60582718 --- /dev/null +++ b/__fixtures__/pkg.js-range-deps/packages/pkg-c/package.json @@ -0,0 +1,4 @@ +{ + "name": "range-deps-pkg-c", + "version": "1.0.0" +} diff --git a/__fixtures__/pkg.js-range-deps/packages/pkg-d/package.json b/__fixtures__/pkg.js-range-deps/packages/pkg-d/package.json new file mode 100644 index 00000000..5d2620d3 --- /dev/null +++ b/__fixtures__/pkg.js-range-deps/packages/pkg-d/package.json @@ -0,0 +1,4 @@ +{ + "name": "range-deps-pkg-d", + "version": "1.0.0" +} diff --git a/__fixtures__/pkg.rust-single-nested/crates/pkg-a/Cargo.toml b/__fixtures__/pkg.rust-single-nested/crates/pkg-a/Cargo.toml new file mode 100644 index 00000000..7bbe1fcd --- /dev/null +++ b/__fixtures__/pkg.rust-single-nested/crates/pkg-a/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_single_nested_fixture" +version = "0.5.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps-inherited/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-inherited/Cargo.toml new file mode 100644 index 00000000..9261f5d1 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-inherited/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["pkg-a", "pkg-b"] + +[workspace.package] +version = "1.2.3" + +[workspace.dependencies] +rust_root_inherited_fixture = { version = "1.2", path = "pkg-a" } +rust_root_inherited_helper_fixture = { path = "pkg-b", default-features = false } diff --git a/__fixtures__/pkg.rust-workspace-root-deps-inherited/pkg-a/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-inherited/pkg-a/Cargo.toml new file mode 100644 index 00000000..4d0bada8 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-inherited/pkg-a/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "rust_root_inherited_fixture" +version.workspace = true + +[dependencies] +rust_root_inherited_helper_fixture = { workspace = true } diff --git a/__fixtures__/pkg.rust-workspace-root-deps-inherited/pkg-b/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-inherited/pkg-b/Cargo.toml new file mode 100644 index 00000000..0ed1c29f --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-inherited/pkg-b/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_root_inherited_helper_fixture" +version.workspace = true diff --git a/__fixtures__/pkg.rust-workspace-root-deps-multi/core/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-multi/core/Cargo.toml new file mode 100644 index 00000000..cb9c4f64 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-multi/core/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["pkg-a", "pkg-b"] + +[workspace.dependencies] +serde = "1.0" +rust_multi_root_pkg_a_fixture = { version = "0.5", path = "pkg-a" } diff --git a/__fixtures__/pkg.rust-workspace-root-deps-multi/core/pkg-a/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-multi/core/pkg-a/Cargo.toml new file mode 100644 index 00000000..40a5bdfd --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-multi/core/pkg-a/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_multi_root_pkg_a_fixture" +version = "0.5.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps-multi/core/pkg-b/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-multi/core/pkg-b/Cargo.toml new file mode 100644 index 00000000..8f6ca313 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-multi/core/pkg-b/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_multi_root_pkg_b_fixture" +version = "0.2.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps-multi/tools/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-multi/tools/Cargo.toml new file mode 100644 index 00000000..9dd32d51 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-multi/tools/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["pkg-c"] + +[workspace.dependencies] +rust_multi_root_pkg_a_fixture = { version = "^0.5", path = "../core/pkg-a" } +rust_multi_root_pkg_c_fixture = { version = "1.0", path = "pkg-c" } diff --git a/__fixtures__/pkg.rust-workspace-root-deps-multi/tools/pkg-c/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps-multi/tools/pkg-c/Cargo.toml new file mode 100644 index 00000000..4272f788 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps-multi/tools/pkg-c/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "rust_multi_root_pkg_c_fixture" +version = "1.0.0" + +[dependencies] +rust_multi_root_pkg_a_fixture = { workspace = true } diff --git a/__fixtures__/pkg.rust-workspace-root-deps/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/Cargo.toml new file mode 100644 index 00000000..8f3fa57f --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +members = ["pkg-a", "pkg-b", "pkg-c", "pkg-d", "pkg-e", "pkg-f", "pkg-g"] + +[workspace.dependencies] +serde = "1.0" +rust_root_pkg_a_fixture = { version = "0.5", path = "pkg-a", default-features = false } +rust_root_pkg_b_fixture = "^0.8.0" +rust_root_pkg_c_fixture = { path = "pkg-c", version = "*" } +rust_root_pkg_d_fixture = { path = "pkg-d" } +rust_root_pkg_e_fixture = ">=0.2, <0.4" +rust_root_pkg_f_fixture = "0.*" +rust_root_pkg_g_fixture = "=0.5" diff --git a/__fixtures__/pkg.rust-workspace-root-deps/pkg-a/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/pkg-a/Cargo.toml new file mode 100644 index 00000000..f7c9a465 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/pkg-a/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rust_root_pkg_a_fixture" +version = "0.5.0" + +[dependencies] +serde = { workspace = true } +rust_root_pkg_b_fixture = { workspace = true } +rust_root_pkg_c_fixture = { workspace = true } + +[dev-dependencies] +rust_root_pkg_d_fixture = { workspace = true } + +[target."cfg(windows)".dependencies] +rust_root_pkg_g_fixture = { workspace = true } diff --git a/__fixtures__/pkg.rust-workspace-root-deps/pkg-b/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/pkg-b/Cargo.toml new file mode 100644 index 00000000..7508ee05 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/pkg-b/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_root_pkg_b_fixture" +version = "0.8.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps/pkg-c/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/pkg-c/Cargo.toml new file mode 100644 index 00000000..e0c666f2 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/pkg-c/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_root_pkg_c_fixture" +version = "0.3.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps/pkg-d/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/pkg-d/Cargo.toml new file mode 100644 index 00000000..2df653cc --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/pkg-d/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_root_pkg_d_fixture" +version = "0.2.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps/pkg-e/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/pkg-e/Cargo.toml new file mode 100644 index 00000000..dba3459b --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/pkg-e/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_root_pkg_e_fixture" +version = "0.2.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps/pkg-f/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/pkg-f/Cargo.toml new file mode 100644 index 00000000..d60c6a41 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/pkg-f/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_root_pkg_f_fixture" +version = "0.1.0" diff --git a/__fixtures__/pkg.rust-workspace-root-deps/pkg-g/Cargo.toml b/__fixtures__/pkg.rust-workspace-root-deps/pkg-g/Cargo.toml new file mode 100644 index 00000000..ec139bb4 --- /dev/null +++ b/__fixtures__/pkg.rust-workspace-root-deps/pkg-g/Cargo.toml @@ -0,0 +1,3 @@ +[package] +name = "rust_root_pkg_g_fixture" +version = "0.5.0" diff --git a/packages/apply/src/apply.ts b/packages/apply/src/apply.ts index f77e6d38..ceabfc13 100644 --- a/packages/apply/src/apply.ts +++ b/packages/apply/src/apply.ts @@ -1,6 +1,8 @@ import { type Operation } from "effection"; import { writePkgFile, + saveFile, + readCargoWorkspaceRoots, getPackageFileVersion, setPackageFileVersion, testSerializePkgFile, @@ -54,13 +56,19 @@ export function* apply({ }); if (bump) { - yield* writeAll({ - bumps: bumps.reduce( - (final: PackageFile[], current) => - !current.file ? final : final.concat([current]), - [], - ), + const bumpsToWrite = bumps.reduce( + (final: PackageFile[], current) => + !current.file ? final : final.concat([current]), + [], + ); + yield* writeAll({ bumps: bumpsToWrite, cwd }); + yield* applyWorkspaceRootDepBumps({ + logger, + bumps: bumpsToWrite, + allPackages, cwd, + previewVersion, + logs, }); } else { for (const b of bumps) { @@ -128,6 +136,80 @@ const writeAll = function* ({ } }; +// a cargo workspace root manifest can declare version requirements for +// member packages in its [workspace.dependencies] table, outside the +// members' own manifests. bump those requirements here to track each bumped +// member's new version. entries without a version (path-only) and `*` +// requirements float on the workspace and are left untouched +function* applyWorkspaceRootDepBumps({ + logger, + bumps, + allPackages, + cwd, + previewVersion = "", + logs = true, +}: { + logger: Logger; + bumps: PackageFile[]; + allPackages: Record; + cwd: string; + previewVersion?: string; + logs?: boolean; +}): Operation { + const cargoBumps = bumps.filter( + (b) => + !!b.name && b.file?.filename === "Cargo" && b.file?.extname === ".toml", + ); + if (cargoBumps.length === 0) return; + + // deriveVersionConsideringPartials reads the bumped version off the + // package file record + const packageFiles = { ...allPackages }; + for (const b of cargoBumps) { + packageFiles[b.name!] = b; + } + + const roots = yield* readCargoWorkspaceRoots({ + memberManifestPaths: cargoBumps.map((b) => b.file!.path), + cwd, + }); + + for (const root of roots) { + let modified = false; + for (const b of cargoBumps) { + const depName = b.pkg.package?.name || b.pkg.name || b.name!; + const key = `workspace.dependencies.${depName}`; + if (!root.doc.has(key)) continue; + const entry = root.doc.get(key); + const prevVersion = typeof entry === "string" ? entry : entry?.version; + if (typeof prevVersion !== "string" || prevVersion === "") continue; + // a requirement that floats or spans a range has no single pin to + // rewrite, so leave it untouched rather than collapse it + if (requirementFloats(prevVersion) || requirementSpansRange(prevVersion)) + continue; + + const version = bumpRequirement({ + requirement: prevVersion, + dependency: b.name!, + previewVersion, + packageFiles, + }); + if (!version) continue; + + root.doc.set(typeof entry === "string" ? key : `${key}.version`, version); + modified = true; + if (logs) { + yield* logger.info( + `bumping ${depName} in ${root.file.path} [workspace.dependencies] to ${version}`, + ); + } + } + if (modified) { + yield* saveFile({ ...root.file, content: root.doc.toString() }, cwd); + } + } +} + function* bumpAll({ logger, changes, @@ -340,7 +422,7 @@ const getDepBumpVersion = ({ dep: string; previewVersion: string; packageFiles: Record; - getPreviousVersion: () => string; + getPreviousVersion: () => string | undefined; }) => { const pkgProperties = Object.keys(currentPkg[property] as object) as Array< keyof Pkg @@ -349,42 +431,90 @@ const getDepBumpVersion = ({ // if pkg is in dep list if (existingDep === depName) { const prevVersion = getPreviousVersion(); - // the pnpm/yarn workspace protocol pins `workspace:*` / `workspace:^` / - // `workspace:~` deps to whatever version the workspace holds, and the - // package manager rewrites them at publish time, so there is no version - // in the declaration to bump (aliased deps, `workspace:name@range`, are - // also left alone); an embedded range such as `workspace:^1.2.3` keeps - // the protocol prefix and bumps the range within it + // a dependency can carry no version of its own: a cargo + // `{ workspace = true }` or path-only declaration reads back empty, + // and one within a `[target]` table reads back undefined. either way + // there is nothing here to bump + if (!prevVersion) return null; + // a pnpm catalog reference (`catalog:` or `catalog:groupname`) points at + // a range kept in pnpm-workspace.yaml and is rewritten by pnpm at + // publish time, so there is no version in the declaration to bump + if (prevVersion.startsWith("catalog:")) return null; + // the pnpm/yarn workspace protocol hands resolution to the package + // manager, which rewrites the declaration at publish time. an aliased + // dep (`workspace:name@range`), and anything the protocol leaves to the + // workspace to resolve (`workspace:*`, `workspace:^`, `workspace:1.x`, + // `workspace:>=1.2 <2`), names no version to bump toward; an embedded + // pin such as `workspace:^1.2.3` keeps the prefix and bumps within it const workspaceProtocol = prevVersion.startsWith("workspace:"); - const range = workspaceProtocol + const requirement = workspaceProtocol ? prevVersion.slice("workspace:".length) : prevVersion; if ( workspaceProtocol && - (range === "*" || range === "^" || range === "~" || range.includes("@")) + (requirementFloats(requirement) || + requirementSpansRange(requirement) || + requirement.includes("@")) ) { return null; } - const versionRequirementMatch = /[\^=~]/.exec(range); - const versionRequirement = versionRequirementMatch - ? versionRequirementMatch[0] - : ""; + if (requirementFloats(requirement) || requirementSpansRange(requirement)) + return null; - const version = deriveVersionConsideringPartials({ + const version = bumpRequirement({ + requirement, dependency: dep, - prevVersion: range, - versionRequirement, previewVersion, packageFiles, }); - if (!version) return version; + if (!version) return null; return workspaceProtocol ? `workspace:${version}` : version; } } return null; }; +// a requirement floats when it names no version to bump toward: `*` and the +// bare `^` / `~` of the workspace protocol take whatever version the +// workspace resolves, and a wildcard such as `1.*` or `1.x` takes any +// version below the wildcard +const requirementFloats = (requirement: string) => + !/\d/.test(requirement) || /(^|\.)[xX*](\.|$)/.test(requirement); + +// a comparator range such as `>=0.2, <0.4` spans versions instead of naming +// one, so there is no single pin to rewrite (range bump policy is tracked in +// #184) +const requirementSpansRange = (requirement: string) => + /[<>,| ]/.test(requirement); + +// rewrite a version requirement around the dependency's bumped version, +// keeping both the comparator it was written with (`^`, `=`, `~`) and its +// precision: `^1.2` stays two part, `1` stays one part. returns null when the +// requirement already covers the bumped version, as a partial pin often does +const bumpRequirement = ({ + requirement, + dependency, + previewVersion, + packageFiles, +}: { + requirement: string; + dependency: string; + previewVersion: string; + packageFiles: Record; +}) => { + const comparatorMatch = /[\^=~]/.exec(requirement); + const version = deriveVersionConsideringPartials({ + dependency, + prevVersion: requirement, + versionRequirement: comparatorMatch ? comparatorMatch[0] : "", + previewVersion, + packageFiles, + }); + if (!version || version === requirement) return null; + return version; +}; + const deriveVersionConsideringPartials = ({ dependency, prevVersion, diff --git a/packages/apply/test/apply.test.ts b/packages/apply/test/apply.test.ts index 939acec1..8ff698fb 100644 --- a/packages/apply/test/apply.test.ts +++ b/packages/apply/test/apply.test.ts @@ -205,6 +205,116 @@ describe("package file apply bump (snapshot)", () => { ]); }); + it("leaves range and wildcard dependency requirements alone", function* () { + const log = yield* logTest.useCapturedLogger(); + const jsonFolder = f.copy("pkg.js-range-deps"); + + const commands = [ + { + dependencies: [ + "range-deps-pkg-b", + "range-deps-pkg-c", + "range-deps-pkg-d", + ], + manager: "javascript", + path: "./packages/pkg-a/", + pkg: "range-deps-pkg-a", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "javascript", + path: "./packages/pkg-b/", + pkg: "range-deps-pkg-b", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "javascript", + path: "./packages/pkg-c/", + pkg: "range-deps-pkg-c", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "javascript", + path: "./packages/pkg-d/", + pkg: "range-deps-pkg-d", + type: "minor", + parents: {}, + }, + ]; + + const config = { + ...configDefaults, + packages: { + "range-deps-pkg-a": { + path: "./packages/pkg-a/", + manager: "javascript", + dependencies: [ + "range-deps-pkg-b", + "range-deps-pkg-c", + "range-deps-pkg-d", + ], + }, + "range-deps-pkg-b": { + path: "./packages/pkg-b/", + manager: "javascript", + }, + "range-deps-pkg-c": { + path: "./packages/pkg-c/", + manager: "javascript", + }, + "range-deps-pkg-d": { + path: "./packages/pkg-d/", + manager: "javascript", + }, + }, + }; + + const allPackages = yield* readAllPkgFiles({ config, cwd: jsonFolder }); + + yield* apply({ + logger: logger.operations, + //@ts-expect-error + commands, + config, + allPackages, + cwd: jsonFolder, + }); + + // a comparator range, a wildcard, and `*` each already cover the bumped + // version and have no single pin to rewrite, so narrowing them onto the + // bumped version would take away range the package deliberately allows + const modifiedPkgAFile = yield* loadFile( + "packages/pkg-a/package.json", + jsonFolder, + ); + expect(modifiedPkgAFile.content).toBe( + "{\n" + + ' "name": "range-deps-pkg-a",\n' + + ' "version": "1.1.0",\n' + + ' "dependencies": {\n' + + ' "range-deps-pkg-b": ">=1.0 <2",\n' + + ' "range-deps-pkg-c": "*"\n' + + " },\n" + + ' "devDependencies": {\n' + + ' "range-deps-pkg-d": "1.x"\n' + + " }\n" + + "}\n", + ); + + yield* logTest.consecutive(log.all, [ + { msg: "bumping range-deps-pkg-a with minor", level: "info" }, + { msg: "bumping range-deps-pkg-b with minor", level: "info" }, + { msg: "bumping range-deps-pkg-c with minor", level: "info" }, + { msg: "bumping range-deps-pkg-d with minor", level: "info" }, + ]); + }); + it("bumps multi with pnpm workspace protocol deps", function* () { const log = yield* logTest.useCapturedLogger(); const jsonFolder = f.copy("pkg.js-pnpm-workspace"); @@ -234,6 +344,14 @@ describe("package file apply bump (snapshot)", () => { type: "minor", parents: {}, }, + { + dependencies: ["pnpm-workspace-pkg-b", "pnpm-workspace-pkg-c"], + manager: "javascript", + path: "./packages/pkg-d/", + pkg: "pnpm-workspace-pkg-d", + type: "minor", + parents: {}, + }, ]; const config = { @@ -253,6 +371,11 @@ describe("package file apply bump (snapshot)", () => { manager: "javascript", dependencies: ["pnpm-workspace-pkg-b"], }, + "pnpm-workspace-pkg-d": { + path: "./packages/pkg-d/", + manager: "javascript", + dependencies: ["pnpm-workspace-pkg-b", "pnpm-workspace-pkg-c"], + }, }, }; @@ -313,10 +436,134 @@ describe("package file apply bump (snapshot)", () => { "}\n", ); + // a wildcard or comparator range behind the protocol prefix is the + // workspace's to resolve as much as a bare `workspace:*` is, so it is + // left alone rather than collapsed onto the bumped version + const modifiedPkgDFile = yield* loadFile( + "packages/pkg-d/package.json", + jsonFolder, + ); + expect(modifiedPkgDFile.content).toBe( + "{\n" + + ' "name": "pnpm-workspace-pkg-d",\n' + + ' "version": "1.1.0",\n' + + ' "dependencies": {\n' + + ' "pnpm-workspace-pkg-b": "workspace:1.x"\n' + + " },\n" + + ' "devDependencies": {\n' + + ' "pnpm-workspace-pkg-c": "workspace:>=1.0 <2"\n' + + " }\n" + + "}\n", + ); + yield* logTest.consecutive(log.all, [ { msg: "bumping pnpm-workspace-pkg-a with minor", level: "info" }, { msg: "bumping pnpm-workspace-pkg-b with minor", level: "info" }, { msg: "bumping pnpm-workspace-pkg-c with minor", level: "info" }, + { msg: "bumping pnpm-workspace-pkg-d with minor", level: "info" }, + ]); + }); + + it("bumps multi with pnpm catalog deps", function* () { + const log = yield* logTest.useCapturedLogger(); + const jsonFolder = f.copy("pkg.js-pnpm-catalog"); + + const commands = [ + { + dependencies: ["js-catalog-pkg-b", "js-catalog-pkg-c"], + manager: "javascript", + path: "./packages/pkg-a/", + pkg: "js-catalog-pkg-a", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "javascript", + path: "./packages/pkg-b/", + pkg: "js-catalog-pkg-b", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "javascript", + path: "./packages/pkg-c/", + pkg: "js-catalog-pkg-c", + type: "minor", + parents: {}, + }, + ]; + + const config = { + ...configDefaults, + packages: { + "js-catalog-pkg-a": { + path: "./packages/pkg-a/", + manager: "javascript", + dependencies: ["js-catalog-pkg-b", "js-catalog-pkg-c"], + }, + "js-catalog-pkg-b": { + path: "./packages/pkg-b/", + manager: "javascript", + }, + "js-catalog-pkg-c": { + path: "./packages/pkg-c/", + manager: "javascript", + }, + }, + }; + + const allPackages = yield* readAllPkgFiles({ config, cwd: jsonFolder }); + const originalWorkspaceFile = yield* loadFile( + "pnpm-workspace.yaml", + jsonFolder, + ); + + yield* apply({ + logger: logger.operations, + //@ts-expect-error + commands, + config, + allPackages, + cwd: jsonFolder, + }); + + // a `catalog:` reference points at a range kept in pnpm-workspace.yaml + // and is rewritten by pnpm at publish time, so the declaration itself + // must survive a cascade bump byte-for-byte + const modifiedPkgAFile = yield* loadFile( + "packages/pkg-a/package.json", + jsonFolder, + ); + expect(modifiedPkgAFile.content).toBe( + "{\n" + + ' "name": "js-catalog-pkg-a",\n' + + ' "version": "1.1.0",\n' + + ' "dependencies": {\n' + + ' "react": "catalog:",\n' + + ' "js-catalog-pkg-b": "catalog:"\n' + + " },\n" + + ' "devDependencies": {\n' + + ' "js-catalog-pkg-c": "catalog:tools"\n' + + " }\n" + + "}\n", + ); + + // the catalog tables are managed manually (pnpm does not document + // catalog entries for workspace-internal packages), so the workspace + // manifest is never rewritten: it survives byte-for-byte as checked + // out, line endings included + const modifiedWorkspaceFile = yield* loadFile( + "pnpm-workspace.yaml", + jsonFolder, + ); + expect(modifiedWorkspaceFile.content).toBe(originalWorkspaceFile.content); + + yield* logTest.consecutive(log.all, [ + { msg: "bumping js-catalog-pkg-a with minor", level: "info" }, + { msg: "bumping js-catalog-pkg-b with minor", level: "info" }, + { msg: "bumping js-catalog-pkg-c with minor", level: "info" }, ]); }); @@ -672,6 +919,397 @@ describe("package file apply bump (snapshot)", () => { ]); }); + it("bumps multi with workspace root dependency requirements", function* () { + const log = yield* logTest.useCapturedLogger(); + const rustFolder = f.copy("pkg.rust-workspace-root-deps"); + + const commands = [ + { + dependencies: [ + "rust_root_pkg_b_fixture", + "rust_root_pkg_c_fixture", + "rust_root_pkg_d_fixture", + "rust_root_pkg_g_fixture", + ], + manager: "rust", + path: "./pkg-a/", + pkg: "rust_root_pkg_a_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "rust", + path: "./pkg-b/", + pkg: "rust_root_pkg_b_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "rust", + path: "./pkg-c/", + pkg: "rust_root_pkg_c_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "rust", + path: "./pkg-d/", + pkg: "rust_root_pkg_d_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "rust", + path: "./pkg-e/", + pkg: "rust_root_pkg_e_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "rust", + path: "./pkg-f/", + pkg: "rust_root_pkg_f_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "rust", + path: "./pkg-g/", + pkg: "rust_root_pkg_g_fixture", + type: "minor", + parents: {}, + }, + ]; + + const config = { + ...configDefaults, + packages: { + rust_root_pkg_a_fixture: { + path: "./pkg-a/", + manager: "rust", + }, + rust_root_pkg_b_fixture: { + path: "./pkg-b/", + manager: "rust", + }, + rust_root_pkg_c_fixture: { + path: "./pkg-c/", + manager: "rust", + }, + rust_root_pkg_d_fixture: { + path: "./pkg-d/", + manager: "rust", + }, + rust_root_pkg_e_fixture: { + path: "./pkg-e/", + manager: "rust", + }, + rust_root_pkg_f_fixture: { + path: "./pkg-f/", + manager: "rust", + }, + rust_root_pkg_g_fixture: { + path: "./pkg-g/", + manager: "rust", + }, + }, + }; + + const allPackages = yield* readAllPkgFiles({ config, cwd: rustFolder }); + + yield* apply({ + logger: logger.operations, + //@ts-expect-error + commands, + config, + allPackages, + cwd: rustFolder, + }); + + // requirements for member crates in the root [workspace.dependencies] + // table track the bumped versions: partial pins stay partial, exact + // pins and range prefixes are kept, and path-only, `*`, + // comparator-range, or wildcard entries are left untouched + const modifiedRootFile = yield* loadFile("Cargo.toml", rustFolder); + expect(modifiedRootFile.content).toBe( + "[workspace]\n" + + 'members = ["pkg-a", "pkg-b", "pkg-c", "pkg-d", "pkg-e", "pkg-f", "pkg-g"]\n' + + "\n" + + "[workspace.dependencies]\n" + + 'serde = "1.0"\n' + + 'rust_root_pkg_a_fixture = { version = "0.6", path = "pkg-a", default-features = false }\n' + + 'rust_root_pkg_b_fixture = "^0.9.0"\n' + + 'rust_root_pkg_c_fixture = { path = "pkg-c", version = "*" }\n' + + 'rust_root_pkg_d_fixture = { path = "pkg-d" }\n' + + 'rust_root_pkg_e_fixture = ">=0.2, <0.4"\n' + + 'rust_root_pkg_f_fixture = "0.*"\n' + + 'rust_root_pkg_g_fixture = "=0.6"\n', + ); + + // the `{ workspace = true }` declarations carry no version of their + // own, in a [target] table as much as anywhere else + const modifiedAPKGFile = yield* loadFile("pkg-a/Cargo.toml", rustFolder); + expect(modifiedAPKGFile.content).toBe( + "[package]\n" + + 'name = "rust_root_pkg_a_fixture"\n' + + 'version = "0.6.0"\n' + + "\n" + + "[dependencies]\n" + + "serde = { workspace = true }\n" + + "rust_root_pkg_b_fixture = { workspace = true }\n" + + "rust_root_pkg_c_fixture = { workspace = true }\n" + + "\n" + + "[dev-dependencies]\n" + + "rust_root_pkg_d_fixture = { workspace = true }\n" + + "\n" + + '[target."cfg(windows)".dependencies]\n' + + "rust_root_pkg_g_fixture = { workspace = true }\n", + ); + + yield* logTest.consecutive(log.all, [ + { + msg: "bumping rust_root_pkg_a_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_pkg_b_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_pkg_c_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_pkg_d_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_pkg_e_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_pkg_f_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_pkg_g_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_pkg_a_fixture in Cargo.toml [workspace.dependencies] to 0.6", + level: "info", + }, + { + msg: "bumping rust_root_pkg_b_fixture in Cargo.toml [workspace.dependencies] to ^0.9.0", + level: "info", + }, + { + msg: "bumping rust_root_pkg_g_fixture in Cargo.toml [workspace.dependencies] to =0.6", + level: "info", + }, + ]); + }); + + it("bumps workspace root dependency requirements across multiple roots", function* () { + const log = yield* logTest.useCapturedLogger(); + const rustFolder = f.copy("pkg.rust-workspace-root-deps-multi"); + + const commands = [ + { + dependencies: undefined, + manager: "rust", + path: "./core/pkg-a/", + pkg: "rust_multi_root_pkg_a_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: undefined, + manager: "rust", + path: "./core/pkg-b/", + pkg: "rust_multi_root_pkg_b_fixture", + type: "minor", + parents: {}, + }, + { + dependencies: ["rust_multi_root_pkg_a_fixture"], + manager: "rust", + path: "./tools/pkg-c/", + pkg: "rust_multi_root_pkg_c_fixture", + type: "minor", + parents: {}, + }, + ]; + + const config = { + ...configDefaults, + packages: { + rust_multi_root_pkg_a_fixture: { + path: "./core/pkg-a/", + manager: "rust", + }, + rust_multi_root_pkg_b_fixture: { + path: "./core/pkg-b/", + manager: "rust", + }, + rust_multi_root_pkg_c_fixture: { + path: "./tools/pkg-c/", + manager: "rust", + }, + }, + }; + + const allPackages = yield* readAllPkgFiles({ config, cwd: rustFolder }); + + yield* apply({ + logger: logger.operations, + //@ts-expect-error + commands, + config, + allPackages, + cwd: rustFolder, + }); + + // each workspace root in the repo is bumped, and a member that no root + // declares (pkg-b) leaves every [workspace.dependencies] table alone + const modifiedCoreRootFile = yield* loadFile( + "core/Cargo.toml", + rustFolder, + ); + expect(modifiedCoreRootFile.content).toBe( + "[workspace]\n" + + 'members = ["pkg-a", "pkg-b"]\n' + + "\n" + + "[workspace.dependencies]\n" + + 'serde = "1.0"\n' + + 'rust_multi_root_pkg_a_fixture = { version = "0.6", path = "pkg-a" }\n', + ); + + // a root can declare a crate from a sibling workspace by path, so the + // requirement is bumped wherever it is declared + const modifiedToolsRootFile = yield* loadFile( + "tools/Cargo.toml", + rustFolder, + ); + expect(modifiedToolsRootFile.content).toBe( + "[workspace]\n" + + 'members = ["pkg-c"]\n' + + "\n" + + "[workspace.dependencies]\n" + + 'rust_multi_root_pkg_a_fixture = { version = "^0.6", path = "../core/pkg-a" }\n' + + 'rust_multi_root_pkg_c_fixture = { version = "1.1", path = "pkg-c" }\n', + ); + + yield* logTest.consecutive(log.all, [ + { + msg: "bumping rust_multi_root_pkg_a_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_multi_root_pkg_b_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_multi_root_pkg_c_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_multi_root_pkg_a_fixture in core/Cargo.toml [workspace.dependencies] to 0.6", + level: "info", + }, + { + msg: "bumping rust_multi_root_pkg_a_fixture in tools/Cargo.toml [workspace.dependencies] to ^0.6", + level: "info", + }, + { + msg: "bumping rust_multi_root_pkg_c_fixture in tools/Cargo.toml [workspace.dependencies] to 1.1", + level: "info", + }, + ]); + }); + + it("bumps workspace root dependency requirements with an inherited version", function* () { + const log = yield* logTest.useCapturedLogger(); + const rustFolder = f.copy("pkg.rust-workspace-root-deps-inherited"); + + const commands = [ + { + dependencies: undefined, + manager: "rust", + path: "./", + pkg: "rust_root_inherited_fixture", + type: "minor", + parents: {}, + }, + ]; + + const config = { + ...configDefaults, + packages: { + rust_root_inherited_fixture: { + path: "./", + manager: "rust", + }, + }, + }; + + // the member manifests inherit their version from the workspace root + // (`version.workspace = true`) and are never rewritten, so they keep + // their checked-out bytes; compare them against their original content + const originalAPKGFile = yield* loadFile("pkg-a/Cargo.toml", rustFolder); + const originalBPKGFile = yield* loadFile("pkg-b/Cargo.toml", rustFolder); + + const allPackages = yield* readAllPkgFiles({ config, cwd: rustFolder }); + + yield* apply({ + logger: logger.operations, + //@ts-expect-error + commands, + config, + allPackages, + cwd: rustFolder, + }); + + // the package's version lives at [workspace.package] in the root + // manifest, so the root carries both the bumped version and the bumped + // [workspace.dependencies] requirement, while the entry without a + // version (path and features only) is left untouched + const modifiedRootFile = yield* loadFile("Cargo.toml", rustFolder); + expect(modifiedRootFile.content).toBe( + "[workspace]\n" + + 'members = ["pkg-a", "pkg-b"]\n' + + "\n" + + "[workspace.package]\n" + + 'version = "1.3.0"\n' + + "\n" + + "[workspace.dependencies]\n" + + 'rust_root_inherited_fixture = { version = "1.3", path = "pkg-a" }\n' + + 'rust_root_inherited_helper_fixture = { path = "pkg-b", default-features = false }\n', + ); + + const modifiedAPKGFile = yield* loadFile("pkg-a/Cargo.toml", rustFolder); + expect(modifiedAPKGFile.content).toBe(originalAPKGFile.content); + const modifiedBPKGFile = yield* loadFile("pkg-b/Cargo.toml", rustFolder); + expect(modifiedBPKGFile.content).toBe(originalBPKGFile.content); + + yield* logTest.consecutive(log.all, [ + { + msg: "bumping rust_root_inherited_fixture with minor", + level: "info", + }, + { + msg: "bumping rust_root_inherited_fixture in Cargo.toml [workspace.dependencies] to 1.3", + level: "info", + }, + ]); + }); + it("bumps multi with object dep", function* () { const log = yield* logTest.useCapturedLogger(); const rustFolder = f.copy("pkg.rust-multi-object-dep"); diff --git a/packages/files/src/index.ts b/packages/files/src/index.ts index 5972d25d..a885eca9 100644 --- a/packages/files/src/index.ts +++ b/packages/files/src/index.ts @@ -307,6 +307,53 @@ export function* writePkgFile({ return inputFile; } +export type CargoWorkspaceRoot = { + file: LoadedFile; + doc: TomlDocument; +}; + +// a cargo workspace's root manifest can declare version requirements for +// member crates in its [workspace.dependencies] table; find the nearest +// ancestor manifest declaring a [workspace] (cargo's workspace resolution +// rule) for each member manifest so those requirements can be kept in sync +export function* readCargoWorkspaceRoots({ + memberManifestPaths, + cwd, +}: { + memberManifestPaths: string[]; + cwd: string; +}): Operation { + const roots: Record = {}; + for (const manifestPath of memberManifestPaths) { + // the walk starts at the member manifest itself rather than its parent: + // a root manifest can be a package in its own right, and one holding the + // version its members inherit at [workspace.package] is the package + // covector bumps, so its own [workspace.dependencies] table is in scope + let dir = path.posix.dirname(manifestPath); + while (true) { + const rootManifestPath = dir === "." ? "Cargo.toml" : `${dir}/Cargo.toml`; + if (roots[rootManifestPath]) break; + try { + const file = yield* loadFile(rootManifestPath, cwd); + const doc = TomlDocument.parse(file.content); + if (doc.has("workspace")) { + roots[rootManifestPath] = { file, doc }; + break; + } + } catch (error) { + // no manifest at this level, keep walking up + } + // `dirname` reaches a fixed point at the top of the walk (`.` for + // relative paths, `/` for absolute ones), so the loop terminates even + // for paths outside the cwd-relative shape loadFile produces + const parent = path.posix.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + return Object.values(roots); +} + export function* readPreFile({ cwd, changeFolder = ".changes", @@ -342,6 +389,13 @@ export const getPackageFileVersion = ({ return pkg.pkg.version; } else if (pkg.file.extname === ".toml" && pkg?.pkg?.package?.version) { return pkg.pkg.package.version; + } else if ( + pkg.file.extname === ".toml" && + pkg?.pkg?.workspace?.package?.version + ) { + // a workspace root manifest can hold the version its members + // inherit at [workspace.package], mirroring setPackageFileVersion + return pkg.pkg.workspace.package.version; } else if (!pkg.pkg.version) { return ""; } else { diff --git a/packages/files/test/toml.test.ts b/packages/files/test/toml.test.ts index 27a15770..2523fa5d 100644 --- a/packages/files/test/toml.test.ts +++ b/packages/files/test/toml.test.ts @@ -8,6 +8,7 @@ import { setPackageFileVersion, getPackageFileVersion, writePkgFile, + readCargoWorkspaceRoots, } from "../src"; const f = fixtures(__dirname); @@ -173,4 +174,68 @@ describe("toml", () => { }); }); }); + + describe("cargo workspace roots", () => { + it("finds no root for a standalone crate", function* () { + const cargoFolder = f.copy("pkg.rust-single"); + + const roots = yield* readCargoWorkspaceRoots({ + memberManifestPaths: ["Cargo.toml"], + cwd: cargoFolder, + }); + expect(roots).toEqual([]); + }); + + it("finds no root for a nested crate without a workspace above it", function* () { + const cargoFolder = f.copy("pkg.rust-single-nested"); + + const roots = yield* readCargoWorkspaceRoots({ + memberManifestPaths: ["crates/pkg-a/Cargo.toml"], + cwd: cargoFolder, + }); + expect(roots).toEqual([]); + }); + + it("finds a root per workspace when members span several", function* () { + const cargoFolder = f.copy("pkg.rust-workspace-root-deps-multi"); + + const roots = yield* readCargoWorkspaceRoots({ + memberManifestPaths: [ + "core/pkg-a/Cargo.toml", + "core/pkg-b/Cargo.toml", + "tools/pkg-c/Cargo.toml", + ], + cwd: cargoFolder, + }); + expect(roots.map((root) => root.file.path)).toEqual([ + "core/Cargo.toml", + "tools/Cargo.toml", + ]); + }); + + it("finds a root manifest that is the member walked up from", function* () { + const cargoFolder = f.copy("pkg.rust-workspace-root-deps-inherited"); + + // covector bumps the root manifest itself when it holds the version + // its members inherit at [workspace.package] + const roots = yield* readCargoWorkspaceRoots({ + memberManifestPaths: ["Cargo.toml"], + cwd: cargoFolder, + }); + expect(roots.map((root) => root.file.path)).toEqual(["Cargo.toml"]); + }); + + it("terminates on an absolute manifest path", function* () { + const cargoFolder = f.copy("pkg.rust-single"); + + // manifest paths are cwd-relative everywhere covector produces them; + // an absolute path is the worst case for the walk up the directory + // tree, which must still stop at the top rather than loop forever + const roots = yield* readCargoWorkspaceRoots({ + memberManifestPaths: ["/outside/the/tree/Cargo.toml"], + cwd: cargoFolder, + }); + expect(roots).toEqual([]); + }); + }); });