diff --git a/README.md b/README.md index 8b12b7fd..1be85e96 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ After pressing V to enter range selection mode: | ARGUMENT | DESCRIPTION | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| -p, --profiles | Allows you to select the [profile](./docs/profiles.md) (set of targets) to use. If no option is specified, the available ones will be listed.. _(**node** by default)_. | | -c, --bg-color | Change row highlight color. _(Available: **blue**, cyan, magenta, white, red and yellow)_ | | -d, --directory | Set the directory from which to begin searching. By default, starting-point is . | | -D, --delete-all | Automatically delete all node_modules folders that are found. Suggested to be used together with `-x`. | diff --git a/docs/profiles.md b/docs/profiles.md index 000c1ac2..e8366b54 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -39,12 +39,11 @@ Special profile: all - `coverage`: Code coverage reports. Deleting removes historical coverage data. Regenerated by running tests with coverage enabled. - `.nyc_output`: Raw coverage output from `nyc`. Deleting removes raw coverage data. Regenerated on the next run of `nyc`. - `.jest`: Jest's test cache and artifacts. Deleting may slow down the next test run. Regenerated automatically by Jest. -- `dist`: General-purpose directory for compiled or built output. Deleting removes the production-ready assets. Regenerated by running a build script (e.g., `npm run build`). - `gatsby_cache`: Gatsby's internal cache. Deleting may slow down the next build. Regenerated automatically by Gatsby. - `.docusaurus`: Docusaurus build cache and data. Deleting removes generated site files. Regenerated by Docusaurus on the next build. - `.swc`: SWC (Speedy Web Compiler) cache. Deleting may slow down the next compilation. Regenerated automatically by SWC. - `.stylelintcache`: Stylelint's cache for linted files. Deleting forces a full re-lint. Regenerated by Stylelint when run with the `--cache` flag. -- `tmp`: Temporary files and directories. Deleting is generally safe but should be done with caution. Regenerated by the respective tools and processes when needed. +- `deno_cache`: Deno's cache for modules. Deleting is safe. Regenerated when Deno modules are next fetched. ## python diff --git a/src/cli/cli.controller.ts b/src/cli/cli.controller.ts index f9baa862..8e98b3d8 100644 --- a/src/cli/cli.controller.ts +++ b/src/cli/cli.controller.ts @@ -1,11 +1,13 @@ import { ConsoleService, + ProfilesService, ResultsService, SpinnerService, UpdateService, } from './services/index.js'; import { DEFAULT_CONFIG, + DEFAULT_PROFILE, MIN_CLI_COLUMNS_SIZE, UI_POSITIONS, } from '../constants/index.js'; @@ -73,6 +75,7 @@ export class CliController { private readonly uiService: UiService, private readonly scanService: ScanService, private readonly jsonOutputService: JsonOutputService, + private readonly profilesService: ProfilesService, ) {} init(): void { @@ -249,17 +252,47 @@ export class CliController { const options = this.consoleService.getParameters(process.argv); if (options.isTrue('help')) { this.showHelp(); - // eslint-disable-next-line n/no-process-exit - process.exit(0); + this.exitGracefully(); } if (options.isTrue('version')) { this.showProgramVersion(); - // eslint-disable-next-line n/no-process-exit - process.exit(0); + this.exitGracefully(); + } + + if (options.isTrue('profiles') && options.isTrue('target-folder')) { + console.log( + 'Cannot use both --profiles and --target-folder options together.', + ); + this.exitGracefully(); + } + + if ( + options.isTrue('profiles') && + options.getStrings('profiles').length === 0 + ) { + // TODO check user defined + const defaultProfile = DEFAULT_PROFILE; + console.log( + colors.bold(colors.bgYellow(colors.black(' Available profiles '))), + ); + console.log( + `Remember: ${colors.bold(colors.yellow('context matters'))}. What's safe to remove in one project or ecosystem could be important in another.\n`, + ); + console.log( + this.profilesService.getAvailableProfilesToPrint(defaultProfile), + ); + this.exitGracefully(); } + if (options.isTrue('delete-all')) { + if (!options.isTrue('target-folder') || options.isTrue('profiles')) { + // TODO mejorar mensaje e incluir tip buscar lista targets de un profile. + console.log('--delete-all only can be used with --target-folder.'); + this.exitWithError(); + } this.config.deleteAll = true; } + if (options.isTrue('sort-by')) { if (!this.isValidSortParam(options.getString('sort-by'))) { this.invalidSortParam(); @@ -302,6 +335,45 @@ export class CliController { if (options.isTrue('no-check-updates')) { this.config.checkUpdates = false; } + + if (!options.isTrue('target-folder')) { + if (!options.isTrue('profiles')) { + this.logger.info(`Using default profile targets (${DEFAULT_PROFILE})`); + this.config.targets = this.profilesService.getTargetsFromProfiles([ + DEFAULT_PROFILE, + ]); + } else { + const selectedProfiles = options.getStrings('profiles'); + const badProfiles = + this.profilesService.getBadProfiles(selectedProfiles); + + if (badProfiles.length > 0) { + this.logger.warn( + `The following profiles are invalid: ${badProfiles.join(', ')}`, + ); + const profileText = badProfiles.length > 1 ? 'profiles' : 'profile'; + console.log( + colors.bold(colors.bgRed(colors.white(` Invalid ${profileText} `))), + ); + console.log( + `The following ${profileText} are invalid: ${colors.red(badProfiles.join(', '))}.`, + ); + console.log( + `You can list the available profiles with ${colors.bold(colors.green('--profiles'))} command ${colors.gray('(without arguments)')}.`, + ); + this.exitWithError(); + } + + const targets = + this.profilesService.getTargetsFromProfiles(selectedProfiles); + this.logger.info( + `Using profiles ${selectedProfiles.join(', ')} | With targets ${targets.join(', ')}`, + ); + this.config.profiles = selectedProfiles; + this.config.targets = targets; + } + } + if (options.isTrue('target-folder')) { this.config.targets = options.getString('target-folder').split(','); } @@ -542,7 +614,7 @@ export class CliController { error: (error) => this.jsonOutputService.writeError(error), complete: () => { this.jsonOutputService.completeScan(); - process.exit(0); + this.exitGracefully(); }, }); } @@ -571,7 +643,7 @@ export class CliController { private setupJsonModeSignalHandlers(): void { const gracefulShutdown = () => { this.jsonOutputService.handleShutdown(); - process.exit(0); + this.exitGracefully(); }; process.on('SIGINT', gracefulShutdown); @@ -625,15 +697,21 @@ export class CliController { } private exitWithError(): void { - this.uiService.print('\n'); - this.uiService.setRawMode(false); - this.uiService.setCursorVisible(true); + this.resetConsoleState(); const logPath = this.logger.getSuggestLogFilePath(); this.logger.saveToFile(logPath); // eslint-disable-next-line n/no-process-exit process.exit(1); } + private exitGracefully(): void { + this.resetConsoleState(); + const logPath = this.logger.getSuggestLogFilePath(); + this.logger.saveToFile(logPath); + // eslint-disable-next-line n/no-process-exit + process.exit(0); + } + private quit(): void { this.uiService.setRawMode(false); this.uiService.clear(); @@ -646,6 +724,12 @@ export class CliController { process.exit(0); } + private resetConsoleState(): void { + this.uiService.print('\n'); + this.uiService.setRawMode(false); + this.uiService.setCursorVisible(true); + } + private printExitMessage(): void { const { spaceReleased } = this.resultsService.getStats(); new GeneralUi().printExitMessage({ spaceReleased }); diff --git a/src/cli/interfaces/config.interface.ts b/src/cli/interfaces/config.interface.ts index ec7683ca..4c1a7bd4 100644 --- a/src/cli/interfaces/config.interface.ts +++ b/src/cli/interfaces/config.interface.ts @@ -1,4 +1,5 @@ export interface IConfig { + profiles: string[]; folderRoot: string; backgroundColor: string; warningColor: string; diff --git a/src/cli/interfaces/profiles.interface.ts b/src/cli/interfaces/profiles.interface.ts new file mode 100644 index 00000000..c1498cf6 --- /dev/null +++ b/src/cli/interfaces/profiles.interface.ts @@ -0,0 +1,5 @@ +export interface TARGETS_PROFILE { + name: string; + targets: string[]; + description: string; +} diff --git a/src/cli/models/start-parameters.model.ts b/src/cli/models/start-parameters.model.ts index ade1c92b..514af4a4 100644 --- a/src/cli/models/start-parameters.model.ts +++ b/src/cli/models/start-parameters.model.ts @@ -18,4 +18,13 @@ export class StartParameters { return value; } + + getStrings(key: string): string[] { + const value = this.values[key]; + if (!value || typeof value === 'boolean') { + return []; + } + + return value.split(',').map((item) => item.trim()); + } } diff --git a/src/cli/services/index.ts b/src/cli/services/index.ts index 9a48f592..4feec03e 100644 --- a/src/cli/services/index.ts +++ b/src/cli/services/index.ts @@ -5,3 +5,4 @@ export * from './spinner.service.js'; export * from './update.service.js'; export * from './json-output.service.js'; export * from '../../core/services/stream.service.js'; +export * from './profiles.service.js'; diff --git a/src/cli/services/profiles.service.ts b/src/cli/services/profiles.service.ts new file mode 100644 index 00000000..b5739fa9 --- /dev/null +++ b/src/cli/services/profiles.service.ts @@ -0,0 +1,42 @@ +import { DEFAULT_PROFILES } from '../../constants/index.js'; +import colors from 'colors'; + +export class ProfilesService { + getAvailableProfilesToPrint(defaultProfileName?: string): string { + return DEFAULT_PROFILES.reduce((acc, profile) => { + const isDefault = profile.name === defaultProfileName; + const entry = + ` ${colors.green(profile.name)}${isDefault ? colors.italic(' (default)') : ''} - ${profile.description}\n` + + colors.gray(` ${profile.targets.join(colors.italic(','))}\n\n`); + return acc + entry; + }, ''); + } + + /** Return an array of invalid profile names (if not exist or dont have targets). */ + getBadProfiles(profilesNames: string[]): string[] { + const availableProfilesNames = DEFAULT_PROFILES.map( + (profile) => profile.name, + ); + return profilesNames.filter( + (profileName) => !availableProfilesNames.includes(profileName), + ); + } + + getTargetsFromProfiles(profilesNames: string[]): string[] { + const profileMap = new Map(DEFAULT_PROFILES.map((p) => [p.name, p])); + const targets = new Set(); + + for (const name of profilesNames) { + const profile = profileMap.get(name); + if (!profile) { + continue; + } + + for (const target of profile.targets) { + targets.add(target); + } + } + + return Array.from(targets); + } +} diff --git a/src/constants/cli.constants.ts b/src/constants/cli.constants.ts index a31646c4..36094349 100644 --- a/src/constants/cli.constants.ts +++ b/src/constants/cli.constants.ts @@ -2,6 +2,12 @@ import { ICliOptions } from '../cli/interfaces/index.js'; import colors from 'colors'; export const OPTIONS: ICliOptions[] = [ + { + arg: ['-p', '--profiles'], + description: + 'Specifies profiles (presets) of folders to search, separated by commas (e.g., `-p python,java`, `-p all`). If used without a value, lists the available profiles. Default: `node`.', + name: 'profiles', + }, { arg: ['-c', '--bg-color'], description: @@ -67,7 +73,7 @@ export const OPTIONS: ICliOptions[] = [ arg: ['-t', '--target'], description: // eslint-disable-next-line quotes - "Specify the name of the directories you want to search for (by default, it's 'node_modules'). You can define multiple targets separating with comma. Ej. `-t node_modules,.cache,`.", + 'Specify the name of the directories you want to search for. You can define multiple targets separating with comma. Ej. `-t node_modules,.cache`.', name: 'target-folder', }, { diff --git a/src/constants/index.ts b/src/constants/index.ts index b8bc119d..855bc7af 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -6,3 +6,4 @@ export * from './spinner.constants.js'; export * from './update.constants.js'; export * from './options.constants.js'; export * from './result-descriptions.constants.js'; +export * from './profiles.constants.js'; diff --git a/src/constants/main.constants.ts b/src/constants/main.constants.ts index 45c0e9f3..dfe1d95e 100644 --- a/src/constants/main.constants.ts +++ b/src/constants/main.constants.ts @@ -1,4 +1,5 @@ import { IConfig } from '../cli/interfaces/index.js'; +import { DEFAULT_PROFILE } from './profiles.constants.js'; export const MIN_CLI_COLUMNS_SIZE = 60; export const CURSOR_SIMBOL = '~>'; @@ -8,6 +9,7 @@ export const DECIMALS_SIZE = 2; export const OVERFLOW_CUT_FROM = 11; export const DEFAULT_CONFIG: IConfig = { + profiles: [DEFAULT_PROFILE], folderRoot: '', backgroundColor: 'bgBlue', warningColor: 'brightYellow', diff --git a/src/constants/profiles.constants.ts b/src/constants/profiles.constants.ts new file mode 100644 index 00000000..35975337 --- /dev/null +++ b/src/constants/profiles.constants.ts @@ -0,0 +1,177 @@ +/* eslint-disable quotes */ +import { TARGETS_PROFILE } from '../cli/interfaces/profiles.interface.js'; + +export const DEFAULT_PROFILE = 'node'; + +const BASE_PROFILES: TARGETS_PROFILE[] = [ + { + name: 'node', + description: + 'All the usual suspects related with the node/web/javascript dev toolchain: node_modules, caches, build artifacts, and assorted JavaScript junk. Safe to clean and your disk will thank you.', + targets: [ + 'node_modules', + '.npm', + '.pnpm-store', + '.yarn/cache', + '.next', + '.nuxt', + '.angular', + '.svelte-kit', + '.vite', + '.nx', + '.turbo', + '.parcel-cache', + '.rpt2_cache', + '.eslintcache', + '.esbuild', + '.cache', + '.rollup.cache', + 'storybook-static', + 'coverage', + '.nyc_output', + '.jest', + 'gatsby_cache', + '.docusaurus', + '.swc', + '.stylelintcache', + 'deno_cache', + ], + }, + { + name: 'python', + description: + 'The usual Python leftovers — caches, virtual environments, and test artifacts. Safe to clear once you’ve closed your IDE and virtualenvs.', + targets: [ + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.ruff_cache', + '.tox', + '.nox', + '.pytype', + '.pyre', + 'htmlcov', + '.venv', + 'venv', + ], + }, + { + name: 'data-science', + description: + 'Jupyter checkpoints, virtualenvs, MLflow runs, and experiment outputs. Great for learning, terrible for disk space.', + targets: [ + '.ipynb_checkpoints', + '__pycache__', + '.venv', + 'venv', + 'outputs', + '.dvc', + '.mlruns', + ], + }, + { + name: 'java', + description: 'Build outputs and Gradle junk.', + targets: ['target', '.gradle', 'out'], + }, + { + name: 'android', + description: + "Native build caches and intermediate files from Android Studio. Deleting won't hurt, but expect a rebuild marathon next time.", + targets: ['.cxx', 'externalNativeBuild'], + }, + { + name: 'swift', + description: + "Xcode's playground leftovers and Swift package builds. Heavy, harmless, and happy to go.", + targets: ['DerivedData', '.swiftpm'], + }, + { + name: 'dotnet', + description: + "Compilation artifacts and Visual Studio cache folders. Disposable once you're done building or testing.", + targets: ['obj', 'TestResults', '.vs'], + }, + { + name: 'rust', + description: + 'Cargo build targets. Huge, regenerable, and surprisingly clingy, your disk will appreciate the reset.', + targets: ['target'], + }, + { + name: 'ruby', + description: 'Bundler caches and dependency leftovers.', + targets: ['.bundle'], + }, + { + name: 'elixir', + description: + 'Mix build folders, dependencies, and coverage reports. Easy to regenerate, safe to purge.', + targets: ['_build', 'deps', 'cover'], + }, + { + name: 'haskell', + description: + "GHC and Stack build outputs. A collection of intermediate binaries you definitely don't need anymore.", + targets: ['dist-newstyle', '.stack-work'], + }, + { + name: 'scala', + description: 'Bloop, Metals, and build outputs from Scala projects.', + targets: ['.bloop', '.metals', 'target'], + }, + { + name: 'cpp', + description: + 'CMake build directories and temporary artifacts. Rebuilds take time, but space is priceless.', + targets: ['CMakeFiles', 'cmake-build-debug', 'cmake-build-release'], + }, + { + name: 'unity', + description: + "Unity's cache and build artifacts. Expect longer load times next launch but it can save tons of space on unused projects.", + targets: ['Library', 'Temp', 'Obj'], + }, + { + name: 'unreal', + description: + 'Intermediate and binary build caches. Safe to clean. Unreal will (happily?) recompile.', + targets: ['Intermediate', 'DerivedDataCache', 'Binaries'], + }, + { + name: 'godot', + description: + 'Editor caches and import data. Godot can recreate these in a blink.', + targets: ['.import', '.godot'], + }, + { + name: 'infra', + description: + 'Leftovers from deployment tools like Serverless, Vercel, Netlify, and Terraform.', + targets: [ + '.serverless', + '.vercel', + '.netlify', + '.terraform', + '.sass-cache', + '.cpcache', + 'elm_stuff', + 'nimcache', + 'deno_cache', + ], + }, +]; + +const ALL_TARGETS = [ + ...new Set(BASE_PROFILES.flatMap((profile) => profile.targets)), +]; + +export const DEFAULT_PROFILES: TARGETS_PROFILE[] = [ + ...BASE_PROFILES, + { + name: 'all', + targets: ALL_TARGETS, + description: + 'Includes all targets listed above. Not recommended, as it mixes unrelated ecosystems and may remove context-specific data (a good recipe for chaos if used recklessly).', + }, +]; diff --git a/src/main.ts b/src/main.ts index 42563e75..ae665e8c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { ConsoleService, HttpsService, JsonOutputService, + ProfilesService, ResultsService, SpinnerService, UpdateService, @@ -36,6 +37,7 @@ export default (): void => { new UiService(), new ScanService(npkill), jsonOutputService, + new ProfilesService(), ); cli.init(); diff --git a/tests/cli/cli.controller.test.ts b/tests/cli/cli.controller.test.ts index 45589c78..0742640d 100644 --- a/tests/cli/cli.controller.test.ts +++ b/tests/cli/cli.controller.test.ts @@ -11,6 +11,8 @@ import { UiService } from '../../src/cli/services/ui.service.js'; import { ScanService } from '../../src/cli/services/scan.service.js'; import { ERROR_MSG } from '../../src/constants/messages.constants.js'; import { JsonOutputService } from '../../src/cli/services/json-output.service.js'; +import { ProfilesService } from '../../src/cli/services/profiles.service.js'; +import { DEFAULT_CONFIG } from '../../src/constants/main.constants.js'; const resultsUiDeleteMock$ = new Subject(); const setDeleteAllWarningVisibilityMock = jest.fn(); @@ -143,6 +145,11 @@ describe('CliController test', () => { startScan$: jest.fn(), delete$: npkillDeleteMock, } as unknown as Npkill; + const profilesServiceMock = { + getAvailableProfilesToPrint: jest.fn(), + getBadProfiles: jest.fn(), + getTargetsFromProfiles: jest.fn(() => ['node_modules']), + }; ////////// mocked Controller Methods let showHelpSpy; @@ -153,6 +160,12 @@ describe('CliController test', () => { /////////////////////////////////// beforeEach(() => { + jest.clearAllMocks(); + + (profilesServiceMock.getTargetsFromProfiles as jest.Mock).mockReturnValue([ + 'node_modules', + ]); + exitSpy = jest.spyOn(process, 'exit').mockImplementation((number) => { throw new Error('process.exit: ' + number); }); @@ -168,6 +181,7 @@ describe('CliController test', () => { uiServiceMock as unknown as UiService, scanServiceMock as unknown as ScanService, jsonOutputServiceMock as unknown as JsonOutputService, + profilesServiceMock as unknown as ProfilesService, ); Object.defineProperty(process.stdout, 'columns', { value: 80 }); @@ -216,6 +230,12 @@ describe('CliController test', () => { afterEach(() => { jest.spyOn(process, 'exit').mockReset(); mockParameters({}); + // Reset DEFAULT_CONFIG to avoid test pollution + DEFAULT_CONFIG.jsonStream = false; + DEFAULT_CONFIG.jsonSimple = false; + DEFAULT_CONFIG.deleteAll = false; + DEFAULT_CONFIG.dryRun = false; + DEFAULT_CONFIG.sortBy = 'none'; }); it('#showHelp should called if --help flag is present and exit', () => { @@ -257,10 +277,16 @@ describe('CliController test', () => { describe('--delete-all', () => { beforeEach(() => { jest.clearAllMocks(); + ( + profilesServiceMock.getTargetsFromProfiles as jest.Mock + ).mockReturnValue(['node_modules']); }); - it('Should show a warning before start scan', () => { - mockParameters({ 'delete-all': true }); + it('Should show a warning before start scan with --target defined', () => { + mockParameters({ + 'delete-all': true, + 'target-folder': 'node_modules', + }); expect(setDeleteAllWarningVisibilityMock).toHaveBeenCalledTimes(0); expect(scanSpy).toHaveBeenCalledTimes(0); @@ -270,7 +296,11 @@ describe('CliController test', () => { }); it('Should no show a warning if -y is given', () => { - mockParameters({ 'delete-all': true, yes: true }); + mockParameters({ + 'delete-all': true, + yes: true, + 'target-folder': 'node_modules', + }); expect(setDeleteAllWarningVisibilityMock).toHaveBeenCalledTimes(0); expect(scanSpy).toHaveBeenCalledTimes(0); @@ -292,7 +322,10 @@ describe('CliController test', () => { }); it('Should call normal deleteDir function when no --dry-run is included', () => { - mockParameters({ targets: ['node_modules'], 'dry-run': 'false' }); + mockParameters({ + 'target-folder': 'node_modules', + 'dry-run': 'false', + }); cliController.init(); expect(npkillDeleteMock).toHaveBeenCalledTimes(0); @@ -306,7 +339,7 @@ describe('CliController test', () => { }); it('Should call fake deleteDir function instead of deleteDir', () => { - mockParameters({ targets: ['node_modules'], 'dry-run': true }); + mockParameters({ 'target-folder': 'node_modules', 'dry-run': true }); cliController.init(); expect(npkillDeleteMock).toHaveBeenCalledTimes(0); @@ -324,7 +357,6 @@ describe('CliController test', () => { it('Should enable JSON stream mode when --json-stream is provided', () => { mockParameters({ jsonStream: true }); const setupJsonSignalsSpy = spyMethod('setupJsonModeSignalHandlers'); - const exitWithErrorSpy = spyMethod('exitWithError'); cliController.init(); @@ -335,7 +367,6 @@ describe('CliController test', () => { it('Should enable JSON simple mode when --json is provided', () => { mockParameters({ jsonSimple: true }); const setupJsonSignalsSpy = spyMethod('setupJsonModeSignalHandlers'); - const exitWithErrorSpy = spyMethod('exitWithError'); cliController.init(); diff --git a/tests/cli/services/profiles.service.test.ts b/tests/cli/services/profiles.service.test.ts new file mode 100644 index 00000000..6d7cefd6 --- /dev/null +++ b/tests/cli/services/profiles.service.test.ts @@ -0,0 +1,301 @@ +import { ProfilesService } from '../../../src/cli/services/profiles.service.js'; +import { DEFAULT_PROFILES } from '../../../src/constants/profiles.constants.js'; + +describe('ProfilesService', () => { + let profilesService: ProfilesService; + + beforeEach(() => { + profilesService = new ProfilesService(); + }); + + describe('getAvailableProfilesToPrint', () => { + it('should return a formatted string with all available profiles', () => { + const result = profilesService.getAvailableProfilesToPrint(); + + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + + DEFAULT_PROFILES.forEach((profile) => { + expect(result).toContain(profile.name); + expect(result).toContain(profile.description); + }); + }); + + it('should mark the specified profile as default', () => { + const defaultProfile = 'python'; + const result = + profilesService.getAvailableProfilesToPrint(defaultProfile); + + expect(result).toContain('python'); + expect(result).toContain('(default)'); + }); + + it('should mark "node" as default when specified', () => { + const result = profilesService.getAvailableProfilesToPrint('node'); + + expect(result).toContain('node'); + + const nodeSection = result + .split('\n') + .find((line) => line.includes('node')); + expect(nodeSection).toContain('(default)'); + }); + + it('should not mark any profile as default when no default is specified', () => { + const result = profilesService.getAvailableProfilesToPrint(); + + expect(result).not.toContain('(default)'); + }); + + it('should include profile targets in the output', () => { + const result = profilesService.getAvailableProfilesToPrint(); + + expect(result).toContain('node_modules'); + expect(result).toContain('__pycache__'); + }); + + it('should return empty string when no profiles exist', () => { + const originalProfiles = [...DEFAULT_PROFILES]; + DEFAULT_PROFILES.length = 0; + + const result = profilesService.getAvailableProfilesToPrint(); + + expect(result).toBe(''); + + DEFAULT_PROFILES.push(...originalProfiles); + }); + }); + + describe('getBadProfiles', () => { + it('should return empty array when all profiles are valid', () => { + const validProfiles = ['node', 'python', 'java']; + const result = profilesService.getBadProfiles(validProfiles); + + expect(result).toEqual([]); + }); + + it('should return array with invalid profile names', () => { + const profiles = ['node', 'invalid-profile', 'python', 'nonexistent']; + const result = profilesService.getBadProfiles(profiles); + + expect(result).toEqual(['invalid-profile', 'nonexistent']); + }); + + it('should return all profiles when none are valid', () => { + const invalidProfiles = ['fake1', 'fake2', 'fake3']; + const result = profilesService.getBadProfiles(invalidProfiles); + + expect(result).toEqual(invalidProfiles); + }); + + it('should return empty array for empty input', () => { + const result = profilesService.getBadProfiles([]); + + expect(result).toEqual([]); + }); + + it('should be case-sensitive', () => { + const profiles = ['Node', 'PYTHON', 'node']; + const result = profilesService.getBadProfiles(profiles); + + expect(result).toEqual(['Node', 'PYTHON']); + }); + + it('should handle profiles with special characters', () => { + const profiles = ['node', 'python!', 'java@']; + const result = profilesService.getBadProfiles(profiles); + + expect(result).toEqual(['python!', 'java@']); + }); + }); + + describe('getTargetsFromProfiles', () => { + it('should return targets for a single profile', () => { + const result = profilesService.getTargetsFromProfiles(['node']); + + expect(result).toBeInstanceOf(Array); + expect(result.length).toBeGreaterThan(0); + expect(result).toContain('node_modules'); + expect(result).toContain('.npm'); + }); + + it('should return targets for multiple profiles', () => { + const result = profilesService.getTargetsFromProfiles(['node', 'python']); + + expect(result).toBeInstanceOf(Array); + expect(result.length).toBeGreaterThan(0); + + expect(result).toContain('node_modules'); + + expect(result).toContain('__pycache__'); + expect(result).toContain('.pytest_cache'); + }); + + it('should remove duplicate targets from multiple profiles', () => { + const result = profilesService.getTargetsFromProfiles(['node', 'python']); + + const uniqueTargets = [...new Set(result)]; + expect(result.length).toBe(uniqueTargets.length); + }); + + it('should return empty array for invalid profile names', () => { + const result = profilesService.getTargetsFromProfiles([ + 'invalid-profile', + ]); + + expect(result).toEqual([]); + }); + + it('should return empty array for empty input', () => { + const result = profilesService.getTargetsFromProfiles([]); + + expect(result).toEqual([]); + }); + + it('should skip invalid profiles and return targets from valid ones', () => { + const result = profilesService.getTargetsFromProfiles([ + 'node', + 'invalid-profile', + 'python', + ]); + + expect(result.length).toBeGreaterThan(0); + expect(result).toContain('node_modules'); + expect(result).toContain('__pycache__'); + }); + + it('should handle the "all" profile correctly', () => { + const allProfileResult = profilesService.getTargetsFromProfiles(['all']); + const nodeResult = profilesService.getTargetsFromProfiles(['node']); + const pythonResult = profilesService.getTargetsFromProfiles(['python']); + + expect(allProfileResult.length).toBeGreaterThan(nodeResult.length); + expect(allProfileResult.length).toBeGreaterThan(pythonResult.length); + + expect(allProfileResult).toContain('node_modules'); + expect(allProfileResult).toContain('__pycache__'); + }); + + it('should maintain target uniqueness when using "all" profile with other profiles', () => { + const result = profilesService.getTargetsFromProfiles([ + 'all', + 'node', + 'python', + ]); + + const uniqueTargets = [...new Set(result)]; + expect(result.length).toBe(uniqueTargets.length); + }); + + it('should return targets in a consistent order for the same input', () => { + const result1 = profilesService.getTargetsFromProfiles([ + 'node', + 'python', + ]); + const result2 = profilesService.getTargetsFromProfiles([ + 'node', + 'python', + ]); + + expect(result1).toEqual(result2); + }); + + it('should handle profile names with different order', () => { + const result1 = profilesService.getTargetsFromProfiles([ + 'node', + 'python', + ]); + const result2 = profilesService.getTargetsFromProfiles([ + 'python', + 'node', + ]); + + expect(result1.sort()).toEqual(result2.sort()); + }); + + it('should handle all available profiles', () => { + const allProfileNames = DEFAULT_PROFILES.map((p) => p.name).filter( + (name) => name !== 'all', + ); + const result = profilesService.getTargetsFromProfiles(allProfileNames); + + expect(result.length).toBeGreaterThan(0); + + expect(result).toContain('node_modules'); + expect(result).toContain('__pycache__'); + }); + }); + + describe('Integration tests', () => { + it('should only return valid targets when mixing valid and invalid profiles', () => { + const badProfiles = profilesService.getBadProfiles([ + 'node', + 'fake-profile', + 'python', + ]); + const validProfiles = ['node', 'fake-profile', 'python'].filter( + (p) => !badProfiles.includes(p), + ); + const targets = profilesService.getTargetsFromProfiles(validProfiles); + + expect(badProfiles).toEqual(['fake-profile']); + expect(targets.length).toBeGreaterThan(0); + expect(targets).toContain('node_modules'); + expect(targets).toContain('__pycache__'); + }); + + it('should handle workflow of listing profiles and getting targets', () => { + const profilesList = profilesService.getAvailableProfilesToPrint('node'); + expect(profilesList).toContain('node'); + expect(profilesList).toContain('(default)'); + + const badProfiles = profilesService.getBadProfiles([ + 'node', + 'invalid', + 'python', + ]); + expect(badProfiles).toEqual(['invalid']); + + const targets = profilesService.getTargetsFromProfiles([ + 'node', + 'python', + ]); + expect(targets.length).toBeGreaterThan(0); + }); + }); + + describe('Edge cases', () => { + it('should handle duplicate profile names in input', () => { + const result = profilesService.getTargetsFromProfiles([ + 'node', + 'node', + 'python', + 'python', + ]); + + const uniqueTargets = [...new Set(result)]; + expect(result.length).toBe(uniqueTargets.length); + }); + + it('should handle very long profile lists', () => { + const allProfiles = DEFAULT_PROFILES.map((p) => p.name); + const longList = [...allProfiles, ...allProfiles, ...allProfiles]; + + const result = profilesService.getTargetsFromProfiles(longList); + + expect(result).toBeInstanceOf(Array); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return consistent results for multiple calls', () => { + const profiles = ['node', 'python', 'java']; + + const result1 = profilesService.getTargetsFromProfiles(profiles); + const result2 = profilesService.getTargetsFromProfiles(profiles); + const result3 = profilesService.getTargetsFromProfiles(profiles); + + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + }); + }); +}); diff --git a/tests/cli/services/scan.service.test.ts b/tests/cli/services/scan.service.test.ts index 91668bb2..050fb804 100644 --- a/tests/cli/services/scan.service.test.ts +++ b/tests/cli/services/scan.service.test.ts @@ -8,6 +8,7 @@ import { import { of, firstValueFrom } from 'rxjs'; import { convertBytesToGb } from '../../../src/utils/unit-conversions.js'; import path from 'node:path'; +import { DEFAULT_PROFILE } from '../../../src/constants/profiles.constants.js'; describe('ScanService', () => { let scanService: ScanService; @@ -19,6 +20,7 @@ describe('ScanService', () => { // Sample data for testing const mockConfig: IConfig = { + profiles: [DEFAULT_PROFILE], folderRoot: '/test/root', targets: ['node_modules'], exclude: ['/test/root/excluded'],