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'],