diff --git a/common/changes/@microsoft/rush/copilot-add-path-project-selector-parser_2025-11-17-22-30.json b/common/changes/@microsoft/rush/copilot-add-path-project-selector-parser_2025-11-17-22-30.json new file mode 100644 index 00000000000..78ce57f1a7c --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-add-path-project-selector-parser_2025-11-17-22-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Added the ability to select projects via path, e.g. `rush build --to path:./my-project` or `rush build --only path:/some/absolute/path`", + "type": "minor", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "198982749+Copilot@users.noreply.github.com" +} \ No newline at end of file diff --git a/libraries/rush-lib/src/api/test/RushConfiguration.test.ts b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts index a70ebc19b35..f7513eb4a01 100644 --- a/libraries/rush-lib/src/api/test/RushConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/RushConfiguration.test.ts @@ -77,7 +77,7 @@ describe(RushConfiguration.name, () => { expect(rushConfiguration.projectFolderMinDepth).toEqual(1); expect(rushConfiguration.hotfixChangeEnabled).toEqual(true); - expect(rushConfiguration.projects).toHaveLength(3); + expect(rushConfiguration.projects).toHaveLength(5); // "approvedPackagesPolicy" feature const approvedPackagesPolicy: ApprovedPackagesPolicy = rushConfiguration.approvedPackagesPolicy; diff --git a/libraries/rush-lib/src/api/test/repo/apps/app1/package.json b/libraries/rush-lib/src/api/test/repo/apps/app1/package.json new file mode 100644 index 00000000000..571970cfa29 --- /dev/null +++ b/libraries/rush-lib/src/api/test/repo/apps/app1/package.json @@ -0,0 +1,5 @@ +{ + "name": "app1", + "version": "1.0.0", + "description": "Test app 1" +} diff --git a/libraries/rush-lib/src/api/test/repo/apps/app2/package.json b/libraries/rush-lib/src/api/test/repo/apps/app2/package.json new file mode 100644 index 00000000000..a030c5e5775 --- /dev/null +++ b/libraries/rush-lib/src/api/test/repo/apps/app2/package.json @@ -0,0 +1,5 @@ +{ + "name": "app2", + "version": "1.0.0", + "description": "Test app 2" +} diff --git a/libraries/rush-lib/src/api/test/repo/common/config/rush/version-policies.json b/libraries/rush-lib/src/api/test/repo/common/config/rush/version-policies.json new file mode 100644 index 00000000000..ad3bf99e03d --- /dev/null +++ b/libraries/rush-lib/src/api/test/repo/common/config/rush/version-policies.json @@ -0,0 +1,8 @@ +[ + { + "definitionName": "lockStepVersion", + "policyName": "testPolicy", + "version": "1.0.0", + "nextBump": "minor" + } +] diff --git a/libraries/rush-lib/src/api/test/repo/rush-npm.json b/libraries/rush-lib/src/api/test/repo/rush-npm.json index a1bead19121..5f448332dcd 100644 --- a/libraries/rush-lib/src/api/test/repo/rush-npm.json +++ b/libraries/rush-lib/src/api/test/repo/rush-npm.json @@ -27,20 +27,37 @@ { "packageName": "project1", "projectFolder": "project1", - "reviewCategory": "third-party" + "reviewCategory": "third-party", + "tags": ["frontend", "ui"], + "versionPolicyName": "testPolicy" }, { "packageName": "project2", "projectFolder": "project2", "reviewCategory": "third-party", - "skipRushCheck": true + "skipRushCheck": true, + "tags": ["backend"] }, { "packageName": "project3", "projectFolder": "project3", - "reviewCategory": "prototype" + "reviewCategory": "prototype", + "tags": ["frontend"], + "versionPolicyName": "testPolicy" + }, + + { + "packageName": "app1", + "projectFolder": "apps/app1", + "reviewCategory": "first-party" + }, + + { + "packageName": "app2", + "projectFolder": "apps/app2", + "reviewCategory": "first-party" } ] } diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 807eb94ef34..7828eb1e90d 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -88,6 +88,13 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _terminal: Terminal; private readonly _autocreateBuildCommand: boolean; + /** + * The current working directory that was used to find the Rush configuration. + */ + public get cwd(): string { + return this._rushOptions.cwd; + } + public constructor(options?: Partial) { super({ toolFilename: 'rush', diff --git a/libraries/rush-lib/src/cli/actions/InstallAction.ts b/libraries/rush-lib/src/cli/actions/InstallAction.ts index 5bee42f2205..ebc828c33b1 100644 --- a/libraries/rush-lib/src/cli/actions/InstallAction.ts +++ b/libraries/rush-lib/src/cli/actions/InstallAction.ts @@ -41,7 +41,8 @@ export class InstallAction extends BaseInstallAction { // Disable filtering because rush-project.json is riggable and therefore may not be available enableFiltering: false }, - includeSubspaceSelector: true + includeSubspaceSelector: true, + cwd: this.parser.cwd }); this._checkOnlyParameter = this.defineFlagParameter({ diff --git a/libraries/rush-lib/src/cli/actions/ListAction.ts b/libraries/rush-lib/src/cli/actions/ListAction.ts index 70d891fb83f..021f3008f86 100644 --- a/libraries/rush-lib/src/cli/actions/ListAction.ts +++ b/libraries/rush-lib/src/cli/actions/ListAction.ts @@ -116,7 +116,8 @@ export class ListAction extends BaseRushAction { // Disable filtering because rush-project.json is riggable and therefore may not be available enableFiltering: false }, - includeSubspaceSelector: false + includeSubspaceSelector: false, + cwd: this.parser.cwd }); } diff --git a/libraries/rush-lib/src/cli/actions/UpdateAction.ts b/libraries/rush-lib/src/cli/actions/UpdateAction.ts index 89490d575e2..f503656bfcc 100644 --- a/libraries/rush-lib/src/cli/actions/UpdateAction.ts +++ b/libraries/rush-lib/src/cli/actions/UpdateAction.ts @@ -45,7 +45,8 @@ export class UpdateAction extends BaseInstallAction { // Disable filtering because rush-project.json is riggable and therefore may not be available enableFiltering: false }, - includeSubspaceSelector: true + includeSubspaceSelector: true, + cwd: this.parser.cwd }); } diff --git a/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts b/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts index cfa1c545788..c8d90bf1a1b 100644 --- a/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts +++ b/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { AlreadyReportedError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; import type { CommandLineParameterProvider, @@ -21,7 +21,7 @@ import { NamedProjectSelectorParser } from '../../logic/selectors/NamedProjectSe import { TagProjectSelectorParser } from '../../logic/selectors/TagProjectSelectorParser'; import { VersionPolicyProjectSelectorParser } from '../../logic/selectors/VersionPolicyProjectSelectorParser'; import { SubspaceSelectorParser } from '../../logic/selectors/SubspaceSelectorParser'; -import { RushConstants } from '../../logic/RushConstants'; +import { PathProjectSelectorParser } from '../../logic/selectors/PathProjectSelectorParser'; import type { Subspace } from '../../api/Subspace'; export const SUBSPACE_LONG_ARG_NAME: '--subspace' = '--subspace'; @@ -29,6 +29,11 @@ export const SUBSPACE_LONG_ARG_NAME: '--subspace' = '--subspace'; interface ISelectionParameterSetOptions { gitOptions: IGitSelectorParserOptions; includeSubspaceSelector: boolean; + /** + * The working directory used to resolve relative paths. + * This should be the same directory that was used to find the Rush configuration. + */ + cwd: string; } /** @@ -58,7 +63,7 @@ export class SelectionParameterSet { action: CommandLineParameterProvider, options: ISelectionParameterSetOptions ) { - const { gitOptions, includeSubspaceSelector } = options; + const { gitOptions, includeSubspaceSelector, cwd } = options; this._rushConfiguration = rushConfiguration; const selectorParsers: Map> = new Map< @@ -72,6 +77,7 @@ export class SelectionParameterSet { selectorParsers.set('tag', new TagProjectSelectorParser(rushConfiguration)); selectorParsers.set('version-policy', new VersionPolicyProjectSelectorParser(rushConfiguration)); selectorParsers.set('subspace', new SubspaceSelectorParser(rushConfiguration)); + selectorParsers.set('path', new PathProjectSelectorParser(rushConfiguration, cwd)); this._selectorParserByScope = selectorParsers; @@ -416,40 +422,36 @@ export class SelectionParameterSet { const selection: Set = new Set(); for (const rawSelector of listParameter.values) { - // Handle the special case of "current project" without a scope - if (rawSelector === '.') { - const packageJsonLookup: PackageJsonLookup = PackageJsonLookup.instance; - const packageJson: IPackageJson | undefined = packageJsonLookup.tryLoadPackageJsonFor(process.cwd()); - if (packageJson) { - const project: RushConfigurationProject | undefined = this._rushConfiguration.getProjectByName( - packageJson.name - ); - - if (project) { - selection.add(project); - } else { - terminal.writeErrorLine( - `Rush is not currently running in a project directory specified in ${RushConstants.rushJsonFilename}. ` + - `The "." value for the ${parameterName} parameter is not allowed.` - ); - throw new AlreadyReportedError(); - } + const scopeIndex: number = rawSelector.indexOf(':'); + + let scope: string; + let unscopedSelector: string; + + if (scopeIndex < 0) { + // No explicit scope - determine if this looks like a path + // Check for relative paths: '.', '..', or those followed by '/' and more + // Check for absolute POSIX paths: starting with '/' + const isRelativePath: boolean = + rawSelector === '.' || + rawSelector === '..' || + rawSelector.startsWith('./') || + rawSelector.startsWith('../'); + const isAbsolutePosixPath: boolean = rawSelector.startsWith('/'); + + if (isRelativePath || isAbsolutePosixPath) { + // Route to path: selector + scope = 'path'; + unscopedSelector = rawSelector; } else { - terminal.writeErrorLine( - 'Rush is not currently running in a project directory. ' + - `The "." value for the ${parameterName} parameter is not allowed.` - ); - throw new AlreadyReportedError(); + // Default to name: selector + scope = 'name'; + unscopedSelector = rawSelector; } - - continue; + } else { + scope = rawSelector.slice(0, scopeIndex); + unscopedSelector = rawSelector.slice(scopeIndex + 1); } - const scopeIndex: number = rawSelector.indexOf(':'); - - const scope: string = scopeIndex < 0 ? 'name' : rawSelector.slice(0, scopeIndex); - const unscopedSelector: string = scopeIndex < 0 ? rawSelector : rawSelector.slice(scopeIndex + 1); - const handler: ISelectorParser | undefined = this._selectorParserByScope.get(scope); if (!handler) { diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index c5f19629af5..b7d7ef8a692 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -236,7 +236,8 @@ export class PhasedScriptAction extends BaseScriptAction i // Enable filtering to reduce evaluation cost enableFiltering: true }, - includeSubspaceSelector: false + includeSubspaceSelector: false, + cwd: this.parser.cwd }); this._verboseParameter = this.defineFlagParameter({ diff --git a/libraries/rush-lib/src/logic/selectors/PathProjectSelectorParser.ts b/libraries/rush-lib/src/logic/selectors/PathProjectSelectorParser.ts new file mode 100644 index 00000000000..eb91e176b2d --- /dev/null +++ b/libraries/rush-lib/src/logic/selectors/PathProjectSelectorParser.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as nodePath from 'node:path'; + +import { AlreadyReportedError, Path } from '@rushstack/node-core-library'; +import type { LookupByPath } from '@rushstack/lookup-by-path'; + +import type { RushConfiguration } from '../../api/RushConfiguration'; +import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import type { IEvaluateSelectorOptions, ISelectorParser } from './ISelectorParser'; +import { RushConstants } from '../RushConstants'; + +export class PathProjectSelectorParser implements ISelectorParser { + private readonly _rushConfiguration: RushConfiguration; + private readonly _workingDirectory: string; + + public constructor(rushConfiguration: RushConfiguration, workingDirectory: string) { + this._rushConfiguration = rushConfiguration; + this._workingDirectory = workingDirectory; + } + + public async evaluateSelectorAsync({ + unscopedSelector, + terminal, + parameterName + }: IEvaluateSelectorOptions): Promise> { + // Resolve the input path against the working directory + const absolutePath: string = nodePath.resolve(this._workingDirectory, unscopedSelector); + + // Relativize it to the rushJsonFolder + const relativePath: string = nodePath.relative(this._rushConfiguration.rushJsonFolder, absolutePath); + + // Normalize path separators to forward slashes for LookupByPath + const normalizedPath: string = Path.convertToSlashes(relativePath); + + // Get the LookupByPath instance for the Rush root + const lookupByPath: LookupByPath = + this._rushConfiguration.getProjectLookupForRoot(this._rushConfiguration.rushJsonFolder); + + // Check if this path is within a project or matches a project exactly + const containingProject: RushConfigurationProject | undefined = + lookupByPath.findChildPath(normalizedPath); + + if (containingProject) { + return [containingProject]; + } + + // Check if there are any projects under this path (i.e., it's a directory containing projects) + const projectsUnderPath: Set = new Set(); + for (const [, project] of lookupByPath.entries(normalizedPath)) { + projectsUnderPath.add(project); + } + + if (projectsUnderPath.size > 0) { + return projectsUnderPath; + } + + // No projects found + terminal.writeErrorLine( + `The path "${unscopedSelector}" passed to "${parameterName}" does not match any project in ` + + `${RushConstants.rushJsonFilename}. The resolved path relative to the Rush root is "${relativePath}".` + ); + throw new AlreadyReportedError(); + } + + public getCompletions(): Iterable { + // Return empty completions as path completions are typically handled by the shell + return []; + } +} diff --git a/libraries/rush-lib/src/logic/selectors/test/NamedProjectSelectorParser.test.ts b/libraries/rush-lib/src/logic/selectors/test/NamedProjectSelectorParser.test.ts new file mode 100644 index 00000000000..9ae8d75cbb5 --- /dev/null +++ b/libraries/rush-lib/src/logic/selectors/test/NamedProjectSelectorParser.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import { NamedProjectSelectorParser } from '../NamedProjectSelectorParser'; + +describe(NamedProjectSelectorParser.name, () => { + let rushConfiguration: RushConfiguration; + let terminal: Terminal; + let terminalProvider: StringBufferTerminalProvider; + let parser: NamedProjectSelectorParser; + + beforeEach(() => { + const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json'); + rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + terminalProvider = new StringBufferTerminalProvider(); + terminal = new Terminal(terminalProvider); + parser = new NamedProjectSelectorParser(rushConfiguration); + }); + + it('should select a project by exact package name', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'project1', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(1); + expect(projects[0].packageName).toBe('project1'); + }); + + it('should throw error for non-existent project', async () => { + await expect( + parser.evaluateSelectorAsync({ + unscopedSelector: 'nonexistent', + terminal, + parameterName: '--only' + }) + ).rejects.toThrow(); + }); + + it('should provide completions for all projects', () => { + const completions = Array.from(parser.getCompletions()); + expect(completions).toContain('project1'); + expect(completions).toContain('project2'); + expect(completions).toContain('project3'); + }); +}); diff --git a/libraries/rush-lib/src/logic/selectors/test/PathProjectSelectorParser.test.ts b/libraries/rush-lib/src/logic/selectors/test/PathProjectSelectorParser.test.ts new file mode 100644 index 00000000000..982b852f23c --- /dev/null +++ b/libraries/rush-lib/src/logic/selectors/test/PathProjectSelectorParser.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import { PathProjectSelectorParser } from '../PathProjectSelectorParser'; + +describe(PathProjectSelectorParser.name, () => { + let rushConfiguration: RushConfiguration; + let terminal: Terminal; + let terminalProvider: StringBufferTerminalProvider; + let parser: PathProjectSelectorParser; + + beforeEach(() => { + const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json'); + rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + terminalProvider = new StringBufferTerminalProvider(); + terminal = new Terminal(terminalProvider); + parser = new PathProjectSelectorParser(rushConfiguration, rushConfiguration.rushJsonFolder); + }); + + it('should select a project by exact path', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'project1', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(1); + expect(projects[0].packageName).toBe('project1'); + }); + + it('should select a project by path within the project', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'project1/src/index.ts', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(1); + expect(projects[0].packageName).toBe('project1'); + }); + + it('should select multiple projects from a parent directory', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: '.', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects.length).toBeGreaterThan(0); + // Should include all projects in the test repo + const packageNames = projects.map((p) => p.packageName).sort(); + expect(packageNames).toContain('project1'); + expect(packageNames).toContain('project2'); + expect(packageNames).toContain('project3'); + }); + + it('should select multiple projects from a shared subfolder', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'apps', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(2); + const packageNames = projects.map((p) => p.packageName).sort(); + expect(packageNames).toEqual(['app1', 'app2']); + }); + + it('should select project from specified directory', async () => { + const project1Path = path.join(rushConfiguration.rushJsonFolder, 'project1'); + const parserWithCustomCwd = new PathProjectSelectorParser(rushConfiguration, project1Path); + + const result = await parserWithCustomCwd.evaluateSelectorAsync({ + unscopedSelector: '.', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(1); + expect(projects[0].packageName).toBe('project1'); + }); + + it('should handle absolute paths', async () => { + const absolutePath = path.join(rushConfiguration.rushJsonFolder, 'project2'); + + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: absolutePath, + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(1); + expect(projects[0].packageName).toBe('project2'); + }); + + it('should throw error for paths that do not match any project', async () => { + await expect( + parser.evaluateSelectorAsync({ + unscopedSelector: 'nonexistent/path', + terminal, + parameterName: '--only' + }) + ).rejects.toThrow(); + }); + + it('should handle paths outside workspace', async () => { + // Paths outside the workspace should not match any project and throw + await expect( + parser.evaluateSelectorAsync({ + unscopedSelector: '../outside', + terminal, + parameterName: '--only' + }) + ).rejects.toThrow(); + }); + + it('should return empty completions', () => { + const completions = Array.from(parser.getCompletions()); + expect(completions).toHaveLength(0); + }); +}); diff --git a/libraries/rush-lib/src/logic/selectors/test/SubspaceSelectorParser.test.ts b/libraries/rush-lib/src/logic/selectors/test/SubspaceSelectorParser.test.ts new file mode 100644 index 00000000000..7508ec45a60 --- /dev/null +++ b/libraries/rush-lib/src/logic/selectors/test/SubspaceSelectorParser.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import { SubspaceSelectorParser } from '../SubspaceSelectorParser'; + +describe(SubspaceSelectorParser.name, () => { + let rushConfiguration: RushConfiguration; + let terminal: Terminal; + let terminalProvider: StringBufferTerminalProvider; + let parser: SubspaceSelectorParser; + + beforeEach(() => { + const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json'); + rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + terminalProvider = new StringBufferTerminalProvider(); + terminal = new Terminal(terminalProvider); + parser = new SubspaceSelectorParser(rushConfiguration); + }); + + it('should return completions based on configuration', () => { + const completions = Array.from(parser.getCompletions()); + // The test fixture doesn't have subspaces configured, so completions may be empty + expect(Array.isArray(completions)).toBe(true); + }); + + it('should select projects from default subspace', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'default', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + // Should get projects from the default subspace + expect(projects.length).toBeGreaterThan(0); + }); +}); diff --git a/libraries/rush-lib/src/logic/selectors/test/TagProjectSelectorParser.test.ts b/libraries/rush-lib/src/logic/selectors/test/TagProjectSelectorParser.test.ts new file mode 100644 index 00000000000..1c78cd5765f --- /dev/null +++ b/libraries/rush-lib/src/logic/selectors/test/TagProjectSelectorParser.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import { TagProjectSelectorParser } from '../TagProjectSelectorParser'; + +describe(TagProjectSelectorParser.name, () => { + let rushConfiguration: RushConfiguration; + let terminal: Terminal; + let terminalProvider: StringBufferTerminalProvider; + let parser: TagProjectSelectorParser; + + beforeEach(() => { + const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json'); + rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + terminalProvider = new StringBufferTerminalProvider(); + terminal = new Terminal(terminalProvider); + parser = new TagProjectSelectorParser(rushConfiguration); + }); + + it('should provide completions for tags', () => { + const completions = Array.from(parser.getCompletions()); + expect(completions.length).toBeGreaterThan(0); + expect(completions).toContain('frontend'); + expect(completions).toContain('backend'); + expect(completions).toContain('ui'); + }); + + it('should select projects by tag', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'frontend', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects.length).toBe(2); + const packageNames = projects.map((p) => p.packageName).sort(); + expect(packageNames).toEqual(['project1', 'project3']); + }); + + it('should select single project by unique tag', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'backend', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(1); + expect(projects[0].packageName).toBe('project2'); + }); + + it('should throw error for non-existent tag', async () => { + await expect( + parser.evaluateSelectorAsync({ + unscopedSelector: 'nonexistent-tag', + terminal, + parameterName: '--only' + }) + ).rejects.toThrow(); + }); +}); diff --git a/libraries/rush-lib/src/logic/selectors/test/VersionPolicyProjectSelectorParser.test.ts b/libraries/rush-lib/src/logic/selectors/test/VersionPolicyProjectSelectorParser.test.ts new file mode 100644 index 00000000000..e84c355cc8c --- /dev/null +++ b/libraries/rush-lib/src/logic/selectors/test/VersionPolicyProjectSelectorParser.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import { VersionPolicyProjectSelectorParser } from '../VersionPolicyProjectSelectorParser'; + +describe(VersionPolicyProjectSelectorParser.name, () => { + let rushConfiguration: RushConfiguration; + let terminal: Terminal; + let terminalProvider: StringBufferTerminalProvider; + let parser: VersionPolicyProjectSelectorParser; + + beforeEach(() => { + const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json'); + rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + terminalProvider = new StringBufferTerminalProvider(); + terminal = new Terminal(terminalProvider); + parser = new VersionPolicyProjectSelectorParser(rushConfiguration); + }); + + it('should return completions for version policies', () => { + const completions = Array.from(parser.getCompletions()); + expect(completions.length).toBeGreaterThan(0); + expect(completions).toContain('testPolicy'); + }); + + it('should select projects by version policy', async () => { + const result = await parser.evaluateSelectorAsync({ + unscopedSelector: 'testPolicy', + terminal, + parameterName: '--only' + }); + + const projects = Array.from(result); + expect(projects).toHaveLength(2); + const packageNames = projects.map((p) => p.packageName).sort(); + expect(packageNames).toEqual(['project1', 'project3']); + }); + + it('should throw error for non-existent version policy', async () => { + await expect( + parser.evaluateSelectorAsync({ + unscopedSelector: 'nonexistent-policy', + terminal, + parameterName: '--only' + }) + ).rejects.toThrow(); + }); +});