diff --git a/.idea/apexdocs.iml b/.idea/apexdocs.iml index 231d18d7..ae7eebf5 100644 --- a/.idea/apexdocs.iml +++ b/.idea/apexdocs.iml @@ -36,6 +36,7 @@ + diff --git a/README.md b/README.md index 8583679c..75658366 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,12 @@ Run the following command to generate markdown files for your global Salesforce ```bash apexdocs markdown -s force-app + +# Use sfdx-project.json as the source of directories +apexdocs markdown --useSfdxProjectJson + +# Specify multiple source directories +apexdocs markdown --sourceDirs force-app force-lwc force-utils ``` #### OpenApi @@ -111,21 +117,31 @@ apexdocs changelog --previousVersionDir force-app-previous --currentVersionDir f #### Flags -| Flag | Alias | Description | Default | Required | -|----------------------------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------| -| `--sourceDir` | `-s` | The directory where the source files are located. | N/A | Yes | -| `--targetDir` | `-t` | The directory where the generated files will be placed. | `docs` | No | -| `--scope` | `-p` | A list of scopes to document. Values should be separated by a space, e.g --scope global public namespaceaccessible. | `[global]` | No | -| `--customObjectVisibility` | `-v` | Controls which custom objects are documented. Values should be separated by a space. | `[public]` | No | -| `--defaultGroupName` | N/A | The default group name to use when a group is not specified. | `Miscellaneous` | No | -| `--namespace` | N/A | The package namespace, if any. If provided, it will be added to the generated files. | N/A | No | -| `--sortAlphabetically` | N/A | Sorts files appearing in the Reference Guide alphabetically, as well as the members of a class, interface or enum alphabetically. If false, the members will be displayed in the same order as the code. | `false` | No | -| `--includeMetadata ` | N/A | Whether to include the file's meta.xml information: Whether it is active and and the API version | `false` | No | -| `--linkingStrategy` | N/A | The strategy to use when linking to other classes. Possible values are `relative`, `no-link`, and `none` | `relative` | No | -| `--customObjectsGroupName` | N/A | The name under which custom objects will be grouped in the Reference Guide | `Custom Objects` | No | -| `--triggersGroupName` | N/A | The name under which triggers will be grouped in the Reference Guide | `Triggers` | No | -| `--includeFieldSecurityMetadata` | N/A | Whether to include the compliance category and security classification for fields in the generated files. | `false` | No | -| `--includeInlineHelpTextMetadata` | N/A | Whether to include the inline help text for fields in the generated files. | `false` | No | +| Flag | Alias | Description | Default | Required | +|-----------------------------------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------| +| `--sourceDir` | `-s` | The directory where the source files are located. | N/A | * | +| `--sourceDirs` | N/A | Multiple source directories (space-separated). Cannot be used with `--sourceDir` or `--useSfdxProjectJson`. | N/A | * | +| `--useSfdxProjectJson` | N/A | Read source directories from `sfdx-project.json` packageDirectories. Cannot be used with `--sourceDir` or `--sourceDirs`. | `false` | * | +| `--sfdxProjectPath` | N/A | Path to directory containing `sfdx-project.json` (defaults to current directory). Only used with `--useSfdxProjectJson`. | `process.cwd()` | No | +| `--targetDir` | `-t` | The directory where the generated files will be placed. | `docs` | No | +| `--scope` | `-p` | A list of scopes to document. Values should be separated by a space, e.g --scope global public namespaceaccessible. | `[global]` | No | +| `--customObjectVisibility` | `-v` | Controls which custom objects are documented. Values should be separated by a space. | `[public]` | No | +| `--defaultGroupName` | N/A | The default group name to use when a group is not specified. | `Miscellaneous` | No | +| `--namespace` | N/A | The package namespace, if any. If provided, it will be added to the generated files. | N/A | No | +| `--sortAlphabetically` | N/A | Sorts files appearing in the Reference Guide alphabetically, as well as the members of a class, interface or enum alphabetically. If false, the members will be displayed in the same order as the code. | `false` | No | +| `--includeMetadata ` | N/A | Whether to include the file's meta.xml information: Whether it is active and and the API version | `false` | No | +| `--linkingStrategy` | N/A | The strategy to use when linking to other classes. Possible values are `relative`, `no-link`, and `none` | `relative` | No | +| `--customObjectsGroupName` | N/A | The name under which custom objects will be grouped in the Reference Guide | `Custom Objects` | No | +| `--triggersGroupName` | N/A | The name under which triggers will be grouped in the Reference Guide | `Triggers` | No | +| `--includeFieldSecurityMetadata` | N/A | Whether to include the compliance category and security classification for fields in the generated files. | `false` | No | +| `--includeInlineHelpTextMetadata` | N/A | Whether to include the inline help text for fields in the generated files. | `false` | No | + +> **Note:** The `*` in the Required column indicates that **one** of the source directory options must be specified: +> - `--sourceDir` (single directory) +> - `--sourceDirs` (multiple directories) +> - `--useSfdxProjectJson` (read from sfdx-project.json) +> +> These options are mutually exclusive - you cannot use more than one at the same time. ##### Linking Strategy @@ -365,7 +381,8 @@ having to copy-paste the same text across multiple classes, polluting your source code. A macro can be defined in your documentation using the `{{macro_name}}` syntax. -In the configuration file, you can then define the macro behavior as a key-value pair, where the key is the name of the macro, and the value is a function that returns the text to inject in place of the macro. +In the configuration file, you can then define the macro behavior as a key-value pair, where the key is the name of the +macro, and the value is a function that returns the text to inject in place of the macro. **Type** @@ -379,7 +396,8 @@ type MacroSourceMetadata = { type MacroFunction = (metadata: MacroSourceMetadata) => string; ``` -Notice that the `metadata` object contains information about the source of the file for which the macro is being injected. This allows you to optionally +Notice that the `metadata` object contains information about the source of the file for which the macro is being +injected. This allows you to optionally return different text based on the source of the file. Example: Injecting a copyright notice @@ -402,13 +420,14 @@ And then in your source code, you can use the macro like this: * @description This is a class */ public class MyClass { - //... + //... } ``` ##### **transformReferenceGuide** -Allows changing the frontmatter and content of the reference guide, or if creating a reference guide page altogether should be skipped. +Allows changing the frontmatter and content of the reference guide, or if creating a reference guide page altogether +should be skipped. **Type** diff --git a/examples/sfdx-multi-dir/README.md b/examples/sfdx-multi-dir/README.md new file mode 100644 index 00000000..0053b12c --- /dev/null +++ b/examples/sfdx-multi-dir/README.md @@ -0,0 +1,101 @@ +# ApexDocs SFDX Project Support Example + +This example demonstrates the new **sfdx-project.json support** feature in ApexDocs, which allows you to automatically read source directories from your Salesforce project configuration instead of manually specifying them. + +## Feature Overview + +ApexDocs now supports three ways to specify source directories: + +1. **Single Directory** (`--sourceDir`) - The traditional approach +2. **Multiple Directories** (`--sourceDirs`) - Specify multiple directories manually +3. **SFDX Project** (`--useSfdxProjectJson`) - Automatically read from `sfdx-project.json` + +## Project Structure + +This example project demonstrates a multi-directory Salesforce project structure: + +``` +sfdx-multi-dir/ +├── sfdx-project.json # Project configuration +├── force-app/ # Main application code +│ └── main/default/classes/ +│ ├── AccountService.cls +│ └── AccountService.cls-meta.xml +└── force-LWC/ # Lightning Web Component helpers + └── main/default/classes/ + ├── LWCHelper.cls + └── LWCHelper.cls-meta.xml +``` + +## Configuration Examples + +### Using sfdx-project.json (Recommended) + +```bash +# Read directories automatically from sfdx-project.json +apexdocs markdown --useSfdxProjectJson --targetDir ./docs --scope public +``` + +The `sfdx-project.json` file: +```json +{ + "packageDirectories": [ + { + "path": "force-app", + "default": true + }, + { + "path": "force-LWC", + "default": false + } + ] +} +``` + +### Using Multiple Directories Manually + +```bash +# Specify multiple directories manually +apexdocs markdown --sourceDirs force-app force-LWC --targetDir ./docs --scope public +``` + +### Using Single Directory + +```bash +# Traditional single directory approach +apexdocs markdown --sourceDir force-app --targetDir ./docs --scope public +``` + +### Using SFDX Project with Custom Path + +If your `sfdx-project.json` is not in the current directory: + +```bash +apexdocs markdown --useSfdxProjectJson --sfdxProjectPath ./my-project --targetDir ./docs +``` + +### Configuration File Support + +You can also use these options in your configuration file: + +**package.json:** +```json +{ + "apexdocs": { + "useSfdxProjectJson": true, + "scope": ["public", "global"], + "targetDir": "./docs" + } +} +``` + +**apexdocs.config.js:** +```javascript +module.exports = { + markdown: { + useSfdxProjectJson: true, + scope: ['public', 'global'], + targetDir: './docs' + } +}; +``` diff --git a/examples/sfdx-multi-dir/docs/account-management/AccountService.md b/examples/sfdx-multi-dir/docs/account-management/AccountService.md new file mode 100644 index 00000000..9dd7fadc --- /dev/null +++ b/examples/sfdx-multi-dir/docs/account-management/AccountService.md @@ -0,0 +1,77 @@ +# AccountService Class + +Service class for handling Account operations + +**Group** Account Management + +**Author** ApexDocs Team + +## Methods +### `getAccountById(accountId)` + +Retrieves an Account record by its Id + +#### Signature +```apex +public static Account getAccountById(Id accountId) +``` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| accountId | Id | The Id of the Account to retrieve | + +#### Return Type +**Account** + +The Account record or null if not found + +#### Example +Account acc = AccountService.getAccountById('0011234567890123'); + +--- + +### `createAccount(accountName, accountType)` + +Creates a new Account record + +#### Signature +```apex +public static Id createAccount(String accountName, String accountType) +``` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| accountName | String | The name for the new Account | +| accountType | String | The type of Account to create | + +#### Return Type +**Id** + +The Id of the newly created Account + +#### Throws +DmlException: if the Account cannot be created + +--- + +### `updateAccountIndustry(accountIds, industry)` + +Updates the industry field for multiple accounts + +#### Signature +```apex +public static Integer updateAccountIndustry(List accountIds, String industry) +``` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| accountIds | List<Id> | List of Account Ids to update | +| industry | String | The industry value to set | + +#### Return Type +**Integer** + +Number of successfully updated accounts \ No newline at end of file diff --git a/examples/sfdx-multi-dir/docs/index.md b/examples/sfdx-multi-dir/docs/index.md new file mode 100644 index 00000000..e9a2fafa --- /dev/null +++ b/examples/sfdx-multi-dir/docs/index.md @@ -0,0 +1,13 @@ +# Reference Guide + +## Account Management + +### [AccountService](account-management/AccountService.md) + +Service class for handling Account operations + +## Lightning Web Components + +### [LWCHelper](lightning-web-components/LWCHelper.md) + +Helper class for Lightning Web Components \ No newline at end of file diff --git a/examples/sfdx-multi-dir/docs/lightning-web-components/LWCHelper.md b/examples/sfdx-multi-dir/docs/lightning-web-components/LWCHelper.md new file mode 100644 index 00000000..80be05a6 --- /dev/null +++ b/examples/sfdx-multi-dir/docs/lightning-web-components/LWCHelper.md @@ -0,0 +1,196 @@ +# LWCHelper Class + +Helper class for Lightning Web Components + +**Group** Lightning Web Components + +**Author** ApexDocs Team + +## Methods +### `getPicklistValues(objectApiName, fieldApiName)` + +`AURAENABLED` + +Retrieves picklist values for a given object and field + +#### Signature +```apex +public static List getPicklistValues(String objectApiName, String fieldApiName) +``` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| objectApiName | String | The API name of the object | +| fieldApiName | String | The API name of the field | + +#### Return Type +**List<PicklistEntry>** + +List of picklist entries with label and value + +#### Example +List<PicklistEntry> entries = LWCHelper.getPicklistValues('Account', 'Industry'); + +--- + +### `getCurrentUserInfo()` + +`AURAENABLED` + +Retrieves current user information for LWC components + +#### Signature +```apex +public static UserInfo getCurrentUserInfo() +``` + +#### Return Type +**UserInfo** + +UserInfo object containing user details + +--- + +### `checkUserPermission(objectApiName, operation)` + +`AURAENABLED` + +Validates user permissions for a specific object and operation + +#### Signature +```apex +public static Boolean checkUserPermission(String objectApiName, String operation) +``` + +#### Parameters +| Name | Type | Description | +|------|------|-------------| +| objectApiName | String | The API name of the object | +| operation | String | The operation to check ('create', 'read', 'update', 'delete') | + +#### Return Type +**Boolean** + +True if user has permission, false otherwise + +## Classes +### PicklistEntry Class + +Inner class to represent picklist entries + +#### Properties +##### `label` + +`AURAENABLED` + +###### Signature +```apex +public label +``` + +###### Type +String + +--- + +##### `value` + +`AURAENABLED` + +###### Signature +```apex +public value +``` + +###### Type +String + +#### Constructors +##### `PicklistEntry(label, value)` + +###### Signature +```apex +public PicklistEntry(String label, String value) +``` + +###### Parameters +| Name | Type | Description | +|------|------|-------------| +| label | String | | +| value | String | | + +### UserInfo Class + +Inner class to represent user information + +#### Properties +##### `userId` + +`AURAENABLED` + +###### Signature +```apex +public userId +``` + +###### Type +Id + +--- + +##### `userName` + +`AURAENABLED` + +###### Signature +```apex +public userName +``` + +###### Type +String + +--- + +##### `userEmail` + +`AURAENABLED` + +###### Signature +```apex +public userEmail +``` + +###### Type +String + +--- + +##### `profileId` + +`AURAENABLED` + +###### Signature +```apex +public profileId +``` + +###### Type +Id + +#### Constructors +##### `UserInfo(userId, userName, userEmail, profileId)` + +###### Signature +```apex +public UserInfo(Id userId, String userName, String userEmail, Id profileId) +``` + +###### Parameters +| Name | Type | Description | +|------|------|-------------| +| userId | Id | | +| userName | String | | +| userEmail | String | | +| profileId | Id | | \ No newline at end of file diff --git a/examples/sfdx-multi-dir/force-LWC/main/default/classes/LWCHelper.cls b/examples/sfdx-multi-dir/force-LWC/main/default/classes/LWCHelper.cls new file mode 100644 index 00000000..818ff49b --- /dev/null +++ b/examples/sfdx-multi-dir/force-LWC/main/default/classes/LWCHelper.cls @@ -0,0 +1,115 @@ +/** + * @description Helper class for Lightning Web Components + * @author ApexDocs Team + * @group Lightning Web Components + */ +public with sharing class LWCHelper { + + /** + * @description Retrieves picklist values for a given object and field + * @param objectApiName The API name of the object + * @param fieldApiName The API name of the field + * @return List of picklist entries with label and value + * @example + * List entries = LWCHelper.getPicklistValues('Account', 'Industry'); + */ + @AuraEnabled(cacheable=true) + public static List getPicklistValues(String objectApiName, String fieldApiName) { + List picklistEntries = new List(); + + try { + Schema.SObjectType objectType = Schema.getGlobalDescribe().get(objectApiName); + Schema.DescribeSObjectResult objectDescribe = objectType.getDescribe(); + Schema.DescribeFieldResult fieldDescribe = objectDescribe.fields.getMap().get(fieldApiName).getDescribe(); + + for (Schema.PicklistEntry entry : fieldDescribe.getPicklistValues()) { + if (entry.isActive()) { + picklistEntries.add(new PicklistEntry(entry.getLabel(), entry.getValue())); + } + } + } catch (Exception e) { + System.debug('Error retrieving picklist values: ' + e.getMessage()); + } + + return picklistEntries; + } + + /** + * @description Retrieves current user information for LWC components + * @return UserInfo object containing user details + */ + @AuraEnabled(cacheable=true) + public static UserInfo getCurrentUserInfo() { + return new UserInfo( + UserInfo.getUserId(), + UserInfo.getName(), + UserInfo.getUserEmail(), + UserInfo.getProfileId() + ); + } + + /** + * @description Validates user permissions for a specific object and operation + * @param objectApiName The API name of the object + * @param operation The operation to check ('create', 'read', 'update', 'delete') + * @return True if user has permission, false otherwise + */ + @AuraEnabled(cacheable=true) + public static Boolean checkUserPermission(String objectApiName, String operation) { + try { + Schema.SObjectType objectType = Schema.getGlobalDescribe().get(objectApiName); + Schema.DescribeSObjectResult objectDescribe = objectType.getDescribe(); + + switch on operation.toLowerCase() { + when 'create' { + return objectDescribe.isCreateable(); + } + when 'read' { + return objectDescribe.isAccessible(); + } + when 'update' { + return objectDescribe.isUpdateable(); + } + when 'delete' { + return objectDescribe.isDeletable(); + } + when else { + return false; + } + } + } catch (Exception e) { + System.debug('Error checking user permissions: ' + e.getMessage()); + return false; + } + } + + /** + * @description Inner class to represent picklist entries + */ + public class PicklistEntry { + @AuraEnabled public String label { get; set; } + @AuraEnabled public String value { get; set; } + + public PicklistEntry(String label, String value) { + this.label = label; + this.value = value; + } + } + + /** + * @description Inner class to represent user information + */ + public class UserInfo { + @AuraEnabled public Id userId { get; set; } + @AuraEnabled public String userName { get; set; } + @AuraEnabled public String userEmail { get; set; } + @AuraEnabled public Id profileId { get; set; } + + public UserInfo(Id userId, String userName, String userEmail, Id profileId) { + this.userId = userId; + this.userName = userName; + this.userEmail = userEmail; + this.profileId = profileId; + } + } +} diff --git a/examples/sfdx-multi-dir/force-LWC/main/default/classes/LWCHelper.cls-meta.xml b/examples/sfdx-multi-dir/force-LWC/main/default/classes/LWCHelper.cls-meta.xml new file mode 100644 index 00000000..7a518297 --- /dev/null +++ b/examples/sfdx-multi-dir/force-LWC/main/default/classes/LWCHelper.cls-meta.xml @@ -0,0 +1,5 @@ + + + 58.0 + Active + diff --git a/examples/sfdx-multi-dir/force-app/main/default/classes/AccountService.cls b/examples/sfdx-multi-dir/force-app/main/default/classes/AccountService.cls new file mode 100644 index 00000000..87de83c0 --- /dev/null +++ b/examples/sfdx-multi-dir/force-app/main/default/classes/AccountService.cls @@ -0,0 +1,76 @@ +/** + * @description Service class for handling Account operations + * @author ApexDocs Team + * @group Account Management + */ +public with sharing class AccountService { + + /** + * @description Retrieves an Account record by its Id + * @param accountId The Id of the Account to retrieve + * @return The Account record or null if not found + * @example + * Account acc = AccountService.getAccountById('0011234567890123'); + */ + public static Account getAccountById(Id accountId) { + try { + return [SELECT Id, Name, Type, Industry FROM Account WHERE Id = :accountId LIMIT 1]; + } catch (QueryException e) { + System.debug('Error retrieving account: ' + e.getMessage()); + return null; + } + } + + /** + * @description Creates a new Account record + * @param accountName The name for the new Account + * @param accountType The type of Account to create + * @return The Id of the newly created Account + * @throws DmlException if the Account cannot be created + */ + public static Id createAccount(String accountName, String accountType) { + Account newAccount = new Account( + Name = accountName, + Type = accountType + ); + + insert newAccount; + return newAccount.Id; + } + + /** + * @description Updates the industry field for multiple accounts + * @param accountIds List of Account Ids to update + * @param industry The industry value to set + * @return Number of successfully updated accounts + */ + public static Integer updateAccountIndustry(List accountIds, String industry) { + List accountsToUpdate = [ + SELECT Id FROM Account + WHERE Id IN :accountIds + ]; + + for (Account acc : accountsToUpdate) { + acc.Industry = industry; + } + + try { + update accountsToUpdate; + return accountsToUpdate.size(); + } catch (DmlException e) { + System.debug('Error updating accounts: ' + e.getMessage()); + return 0; + } + } + + /** + * @description Private helper method to validate account data + * @param accountData The account data to validate + * @return True if valid, false otherwise + */ + private static Boolean validateAccountData(Account accountData) { + return accountData != null && + String.isNotBlank(accountData.Name) && + accountData.Name.length() <= 255; + } +} diff --git a/examples/sfdx-multi-dir/force-app/main/default/classes/AccountService.cls-meta.xml b/examples/sfdx-multi-dir/force-app/main/default/classes/AccountService.cls-meta.xml new file mode 100644 index 00000000..7a518297 --- /dev/null +++ b/examples/sfdx-multi-dir/force-app/main/default/classes/AccountService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 58.0 + Active + diff --git a/examples/sfdx-multi-dir/package-lock.json b/examples/sfdx-multi-dir/package-lock.json new file mode 100644 index 00000000..c4e69395 --- /dev/null +++ b/examples/sfdx-multi-dir/package-lock.json @@ -0,0 +1,234 @@ +{ + "name": "sfdx-multi-dir-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sfdx-multi-dir-example", + "devDependencies": { + "ts-node": "^10.9.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", + "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/examples/sfdx-multi-dir/package.json b/examples/sfdx-multi-dir/package.json new file mode 100644 index 00000000..91a2e57c --- /dev/null +++ b/examples/sfdx-multi-dir/package.json @@ -0,0 +1,10 @@ +{ + "name": "sfdx-multi-dir-example", + "scripts": { + "apexdocs:build": "ts-node ../../src/cli/generate.ts markdown --useSfdxProjectJson --targetDir ./docs --scope public", + "apexdocs:build:single": "ts-node ../../src/cli/generate.ts markdown --sourceDir force-app --targetDir ./docs --scope public" + }, + "devDependencies": { + "ts-node": "^10.9.2" + } +} diff --git a/examples/sfdx-multi-dir/sfdx-project.json b/examples/sfdx-multi-dir/sfdx-project.json new file mode 100644 index 00000000..98b282d8 --- /dev/null +++ b/examples/sfdx-multi-dir/sfdx-project.json @@ -0,0 +1,16 @@ +{ + "packageDirectories": [ + { + "path": "force-app", + "default": true + }, + { + "path": "force-LWC", + "default": false + } + ], + "name": "ApexDocs Multi-Directory Example", + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "58.0" +} diff --git a/package.json b/package.json index 653bcaa9..2aa5fa61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cparra/apexdocs", - "version": "3.12.2", + "version": "3.13.0", "description": "Library with CLI capabilities to generate documentation for Salesforce Apex classes.", "keywords": [ "apex", diff --git a/src/__tests__/integration-sfdx-project.spec.ts b/src/__tests__/integration-sfdx-project.spec.ts new file mode 100644 index 00000000..549c7351 --- /dev/null +++ b/src/__tests__/integration-sfdx-project.spec.ts @@ -0,0 +1,285 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +describe('SFDX Project Integration Tests', () => { + let testDir: string; + let apexdocsPath: string; + + beforeEach(() => { + // Create a temporary directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apexdocs-sfdx-test-')); + + // Get the path to the built apexdocs CLI + apexdocsPath = path.resolve(__dirname, '../../dist/cli/generate.js'); + }); + + afterEach(() => { + // Clean up the temporary directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('sfdx-project.json integration', () => { + it('should generate documentation from multiple directories specified in sfdx-project.json', () => { + // Setup project structure + setupTestProject(testDir); + + // Run apexdocs with useSfdxProjectJson + const command = `node "${apexdocsPath}" markdown --useSfdxProjectJson --targetDir ./docs --scope public`; + const result = execSync(command, { + cwd: testDir, + encoding: 'utf8', + stdio: 'pipe', + }); + + // Verify successful execution + expect(result).toContain('Documentation generated successfully'); + + // Verify generated files exist + const docsDir = path.join(testDir, 'docs'); + expect(fs.existsSync(docsDir)).toBe(true); + expect(fs.existsSync(path.join(docsDir, 'index.md'))).toBe(true); + + // Read and verify index.md content + const indexContent = fs.readFileSync(path.join(docsDir, 'index.md'), 'utf8'); + expect(indexContent).toContain('TestService'); // From force-app + expect(indexContent).toContain('UtilityHelper'); // From force-utils + + // Verify individual class documentation exists + const testServicePath = path.join(docsDir, 'services', 'TestService.md'); + const utilityHelperPath = path.join(docsDir, 'utilities', 'UtilityHelper.md'); + + expect(fs.existsSync(testServicePath)).toBe(true); + expect(fs.existsSync(utilityHelperPath)).toBe(true); + + // Verify content of generated documentation + const testServiceContent = fs.readFileSync(testServicePath, 'utf8'); + expect(testServiceContent).toContain('Test service class'); + expect(testServiceContent).toContain('getTestData'); + + const utilityHelperContent = fs.readFileSync(utilityHelperPath, 'utf8'); + expect(utilityHelperContent).toContain('Utility helper class'); + expect(utilityHelperContent).toContain('formatString'); + }); + + it('should work with sourceDir parameter (multiple directories)', () => { + setupTestProject(testDir); + + const command = `node "${apexdocsPath}" markdown --sourceDir force-app force-utils --targetDir ./docs-manual --scope public`; + const result = execSync(command, { + cwd: testDir, + encoding: 'utf8', + stdio: 'pipe', + }); + + expect(result).toContain('Documentation generated successfully'); + + const docsDir = path.join(testDir, 'docs-manual'); + expect(fs.existsSync(docsDir)).toBe(true); + + const indexContent = fs.readFileSync(path.join(docsDir, 'index.md'), 'utf8'); + expect(indexContent).toContain('TestService'); + expect(indexContent).toContain('UtilityHelper'); + }); + + it('should fail when conflicting source options are provided', () => { + setupTestProject(testDir); + + const command = `node "${apexdocsPath}" markdown --sourceDir force-app --useSfdxProjectJson --targetDir ./docs`; + + expect(() => { + execSync(command, { + cwd: testDir, + encoding: 'utf8', + stdio: 'pipe', + }); + }).toThrow(); + }); + + it('should fail when sfdx-project.json does not exist', () => { + // Create minimal structure without sfdx-project.json + createDirectory(path.join(testDir, 'force-app', 'main', 'default', 'classes')); + + const command = `node "${apexdocsPath}" markdown --useSfdxProjectJson --targetDir ./docs`; + + expect(() => { + execSync(command, { + cwd: testDir, + encoding: 'utf8', + stdio: 'pipe', + }); + }).toThrow(); + }); + + it('should fail when package directories do not exist', () => { + // Create sfdx-project.json with non-existent directories + const sfdxProject = { + packageDirectories: [ + { path: 'non-existent-dir', default: true }, + { path: 'another-missing-dir', default: false }, + ], + }; + + fs.writeFileSync(path.join(testDir, 'sfdx-project.json'), JSON.stringify(sfdxProject, null, 2)); + + const command = `node "${apexdocsPath}" markdown --useSfdxProjectJson --targetDir ./docs`; + + expect(() => { + execSync(command, { + cwd: testDir, + encoding: 'utf8', + stdio: 'pipe', + }); + }).toThrow(); + }); + + it('should work with custom sfdxProjectPath', () => { + // Create project in subdirectory + const projectSubDir = path.join(testDir, 'my-project'); + createDirectory(projectSubDir); + setupTestProject(projectSubDir); + + const command = `node "${apexdocsPath}" markdown --useSfdxProjectJson --sfdxProjectPath ./my-project --targetDir ./docs --scope public`; + const result = execSync(command, { + cwd: testDir, + encoding: 'utf8', + stdio: 'pipe', + }); + + expect(result).toContain('Documentation generated successfully'); + + const docsDir = path.join(testDir, 'docs'); + expect(fs.existsSync(docsDir)).toBe(true); + + const indexContent = fs.readFileSync(path.join(docsDir, 'index.md'), 'utf8'); + expect(indexContent).toContain('TestService'); + expect(indexContent).toContain('UtilityHelper'); + }); + + it('should work with openapi generator', () => { + setupTestProjectWithRestResource(testDir); + + const command = `node "${apexdocsPath}" openapi --useSfdxProjectJson --targetDir ./api-docs --fileName test-api`; + const result = execSync(command, { + cwd: testDir, + encoding: 'utf8', + stdio: 'pipe', + }); + + expect(result).toContain('Documentation generated successfully'); + + // OpenAPI generator may not create output if no REST resources are found + // This is expected behavior, so we just verify the command succeeded + }); + }); + + function setupTestProject(projectDir: string) { + // Create sfdx-project.json + const sfdxProject = { + packageDirectories: [ + { path: 'force-app', default: true }, + { path: 'force-utils', default: false }, + ], + name: 'test-project', + namespace: '', + sfdcLoginUrl: 'https://login.salesforce.com', + sourceApiVersion: '58.0', + }; + + fs.writeFileSync(path.join(projectDir, 'sfdx-project.json'), JSON.stringify(sfdxProject, null, 2)); + + // Create directory structure and files + createTestClass( + projectDir, + 'force-app/main/default/classes', + 'TestService', + `/** + * @description Test service class + * @group Services + */ + public with sharing class TestService { + /** + * @description Gets test data + * @return List of test records + */ + public static List getTestData() { + return new List{'test1', 'test2'}; + } + }`, + ); + + createTestClass( + projectDir, + 'force-utils/main/default/classes', + 'UtilityHelper', + `/** + * @description Utility helper class + * @group Utilities + */ + public with sharing class UtilityHelper { + /** + * @description Formats a string + * @param input The input string + * @return Formatted string + */ + public static String formatString(String input) { + return input != null ? input.trim() : ''; + } + }`, + ); + } + + function setupTestProjectWithRestResource(projectDir: string) { + const sfdxProject = { + packageDirectories: [{ path: 'force-app', default: true }], + }; + + fs.writeFileSync(path.join(projectDir, 'sfdx-project.json'), JSON.stringify(sfdxProject, null, 2)); + + createTestClass( + projectDir, + 'force-app/main/default/classes', + 'TestRestResource', + `/** + * @description Test REST resource + */ + @RestResource(urlMapping='/test/*') + public with sharing class TestRestResource { + /** + * @description Gets test data via REST + * @return Test response + */ + @HttpGet + public static String getTestData() { + return 'test data'; + } + }`, + ); + } + + function createTestClass(projectDir: string, classPath: string, className: string, classContent: string) { + const fullClassPath = path.join(projectDir, classPath); + createDirectory(fullClassPath); + + // Create .cls file + fs.writeFileSync(path.join(fullClassPath, `${className}.cls`), classContent); + + // Create .cls-meta.xml file + const metaXml = ` + + 58.0 + Active +`; + + fs.writeFileSync(path.join(fullClassPath, `${className}.cls-meta.xml`), metaXml); + } + + function createDirectory(dirPath: string) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + } +}); diff --git a/src/application/Apexdocs.ts b/src/application/Apexdocs.ts index 0369760a..e9613726 100644 --- a/src/application/Apexdocs.ts +++ b/src/application/Apexdocs.ts @@ -11,12 +11,12 @@ import { DefaultFileSystem } from './file-system'; import { Logger } from '#utils/logger'; import { UnparsedApexBundle, - UnparsedSourceBundle, UserDefinedChangelogConfig, UserDefinedConfig, UserDefinedMarkdownConfig, UserDefinedOpenApiConfig, } from '../core/shared/types'; +import { resolveAndValidateSourceDirectories } from '../util/source-directory-resolver'; import { ReflectionError, ReflectionErrors, HookError } from '../core/errors/errors'; import { FileReadingError, FileWritingError } from './errors'; @@ -50,12 +50,16 @@ const readFiles = processFiles(new DefaultFileSystem()); async function processMarkdown(config: UserDefinedMarkdownConfig) { return pipe( - E.tryCatch( - () => - readFiles(allComponentTypes, { - includeMetadata: config.includeMetadata, - })(config.sourceDir, config.exclude), - (e) => new FileReadingError('An error occurred while reading files.', e), + resolveAndValidateSourceDirectories(config), + E.mapLeft((error) => new FileReadingError(`Failed to resolve source directories: ${error.message}`, error)), + E.flatMap((sourceDirs) => + E.tryCatch( + () => + readFiles(allComponentTypes, { + includeMetadata: config.includeMetadata, + })(sourceDirs, config.exclude), + (e) => new FileReadingError('An error occurred while reading files.', e), + ), ), TE.fromEither, TE.flatMap((fileBodies) => markdown(fileBodies, config)), @@ -65,20 +69,61 @@ async function processMarkdown(config: UserDefinedMarkdownConfig) { } async function processOpenApi(config: UserDefinedOpenApiConfig, logger: Logger) { - const fileBodies = readFiles(['ApexClass'])(config.sourceDir, config.exclude) as UnparsedApexBundle[]; - return openApi(logger, fileBodies, config); + return pipe( + resolveAndValidateSourceDirectories(config), + E.mapLeft((error) => new FileReadingError(`Failed to resolve source directories: ${error.message}`, error)), + TE.fromEither, + TE.flatMap((sourceDirs) => + TE.tryCatch( + () => { + const fileBodies = readFiles(['ApexClass'])(sourceDirs, config.exclude) as UnparsedApexBundle[]; + return openApi(logger, fileBodies, config); + }, + (e) => new FileReadingError('An error occurred while generating OpenAPI documentation.', e), + ), + ), + ); } async function processChangeLog(config: UserDefinedChangelogConfig) { - function loadFiles(): [UnparsedSourceBundle[], UnparsedSourceBundle[]] { - return [ - readFiles(allComponentTypes)(config.previousVersionDir, config.exclude), - readFiles(allComponentTypes)(config.currentVersionDir, config.exclude), - ]; + function loadFiles() { + const previousVersionConfig = { + sourceDir: config.previousVersionDir, + }; + + const currentVersionConfig = { + sourceDir: config.currentVersionDir, + }; + + return pipe( + E.Do, + E.bind('previousVersionDirs', () => + pipe( + resolveAndValidateSourceDirectories(previousVersionConfig), + E.mapLeft( + (error) => + new FileReadingError(`Failed to resolve previous version source directories: ${error.message}`, error), + ), + ), + ), + E.bind('currentVersionDirs', () => + pipe( + resolveAndValidateSourceDirectories(currentVersionConfig), + E.mapLeft( + (error) => + new FileReadingError(`Failed to resolve current version source directories: ${error.message}`, error), + ), + ), + ), + E.map(({ previousVersionDirs, currentVersionDirs }) => [ + readFiles(allComponentTypes)(previousVersionDirs, config.exclude), + readFiles(allComponentTypes)(currentVersionDirs, config.exclude), + ]), + ); } return pipe( - E.tryCatch(loadFiles, (e) => new FileReadingError('An error occurred while reading files.', e)), + loadFiles(), TE.fromEither, TE.flatMap(([previous, current]) => changelog(previous, current, config)), TE.mapLeft(toErrors), diff --git a/src/application/generators/openapi.ts b/src/application/generators/openapi.ts index 79ecf7ad..1d30b1e2 100644 --- a/src/application/generators/openapi.ts +++ b/src/application/generators/openapi.ts @@ -19,8 +19,14 @@ export default async function openApi( fileBodies: UnparsedApexBundle[], config: UserDefinedOpenApiConfig, ) { + // For backwards compatibility, use sourceDir if provided, otherwise derive from file paths or use current directory + // If sourceDir is an array, use the first directory + const sourceDirectory = + (Array.isArray(config.sourceDir) ? config.sourceDir[0] : config.sourceDir) || + (fileBodies.length > 0 ? fileBodies[0].filePath.split('/').slice(0, -1).join('/') : process.cwd()); + OpenApiSettings.build({ - sourceDirectory: config.sourceDir, + sourceDirectory, outputDir: config.targetDir, openApiFileName: config.fileName, openApiTitle: config.title, diff --git a/src/application/source-code-file-reader.ts b/src/application/source-code-file-reader.ts index 4c4b94d0..d7eaee93 100644 --- a/src/application/source-code-file-reader.ts +++ b/src/application/source-code-file-reader.ts @@ -248,9 +248,13 @@ export function processFiles(fileSystem: FileSystem) { const convertersToUse = componentTypesToRetrieve.map((componentType) => converters[componentType]); - return (rootPath: string, exclude: string[]) => { + return (rootPaths: string | string[], exclude: string[]) => { + const paths = Array.isArray(rootPaths) ? rootPaths : [rootPaths]; + + const allComponents = paths.flatMap((path) => fileSystem.getComponents(path)); + return pipe( - fileSystem.getComponents(rootPath), + allComponents, (components) => { return components.map((component) => { const pathLocation = component.type.name === 'ApexClass' ? component.content : component.xml; diff --git a/src/cli/__tests__/args/multiple-command-config.spec.ts b/src/cli/__tests__/args/multiple-command-config.spec.ts index e7afc977..a546c7f5 100644 --- a/src/cli/__tests__/args/multiple-command-config.spec.ts +++ b/src/cli/__tests__/args/multiple-command-config.spec.ts @@ -34,7 +34,7 @@ describe('when extracting arguments', () => { expect(configs[0].targetGenerator).toEqual('markdown'); const markdownConfig = configs[0] as UserDefinedMarkdownConfig; - expect(markdownConfig.sourceDir).toEqual('force-app'); + expect(markdownConfig.sourceDir).toEqual(['force-app']); }); }); @@ -69,14 +69,14 @@ describe('when extracting arguments', () => { expect(configs[2].targetGenerator).toEqual('changelog'); const markdownConfig = configs[0] as UserDefinedMarkdownConfig; - expect(markdownConfig.sourceDir).toEqual('force-app'); + expect(markdownConfig.sourceDir).toEqual(['force-app']); const openApiConfig = configs[1] as UserDefinedOpenApiConfig; - expect(openApiConfig.sourceDir).toEqual('force-app'); + expect(openApiConfig.sourceDir).toEqual(['force-app']); const changelogConfig = configs[2] as UserDefinedChangelogConfig; - expect(changelogConfig.previousVersionDir).toEqual('previous'); - expect(changelogConfig.currentVersionDir).toEqual('force-app'); + expect(changelogConfig.previousVersionDir).toEqual(['previous']); + expect(changelogConfig.currentVersionDir).toEqual(['force-app']); }); }); diff --git a/src/cli/__tests__/args/no-config.spec.ts b/src/cli/__tests__/args/no-config.spec.ts index 0bc0d21d..0f1b7da8 100644 --- a/src/cli/__tests__/args/no-config.spec.ts +++ b/src/cli/__tests__/args/no-config.spec.ts @@ -36,7 +36,7 @@ describe('when extracting arguments', () => { expect(configs[0].targetGenerator).toEqual('markdown'); const markdownConfig = configs[0] as UserDefinedMarkdownConfig; - expect(markdownConfig.sourceDir).toEqual('force-app'); + expect(markdownConfig.sourceDir).toEqual(['force-app']); }); }); @@ -52,7 +52,7 @@ describe('when extracting arguments', () => { expect(configs[0].targetGenerator).toEqual('openapi'); const openApiConfig = configs[0] as UserDefinedOpenApiConfig; - expect(openApiConfig.sourceDir).toEqual('force-app'); + expect(openApiConfig.sourceDir).toEqual(['force-app']); }); }); @@ -69,8 +69,8 @@ describe('when extracting arguments', () => { const changelogConfig = configs[0] as UserDefinedChangelogConfig; - expect(changelogConfig.previousVersionDir).toEqual('previous'); - expect(changelogConfig.currentVersionDir).toEqual('force-app'); + expect(changelogConfig.previousVersionDir).toEqual(['previous']); + expect(changelogConfig.currentVersionDir).toEqual(['force-app']); }); }); diff --git a/src/cli/__tests__/args/simple-config.spec.ts b/src/cli/__tests__/args/simple-config.spec.ts index bb0480d8..d5f6fa03 100644 --- a/src/cli/__tests__/args/simple-config.spec.ts +++ b/src/cli/__tests__/args/simple-config.spec.ts @@ -45,7 +45,7 @@ describe('when extracting arguments', () => { expect(configs[0].targetGenerator).toEqual('markdown'); const markdownConfig = configs[0] as UserDefinedMarkdownConfig; - expect(markdownConfig.sourceDir).toEqual('force-app'); + expect(markdownConfig.sourceDir).toEqual(['force-app']); }); }); @@ -69,7 +69,7 @@ describe('when extracting arguments', () => { expect(configs[0].targetGenerator).toEqual('openapi'); const openApiConfig = configs[0] as UserDefinedOpenApiConfig; - expect(openApiConfig.sourceDir).toEqual('force-app'); + expect(openApiConfig.sourceDir).toEqual(['force-app']); }); }); @@ -95,8 +95,8 @@ describe('when extracting arguments', () => { const changelogConfig = configs[0] as UserDefinedChangelogConfig; - expect(changelogConfig.previousVersionDir).toEqual('previous'); - expect(changelogConfig.currentVersionDir).toEqual('force-app'); + expect(changelogConfig.previousVersionDir).toEqual(['previous']); + expect(changelogConfig.currentVersionDir).toEqual(['force-app']); }); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 4a0ff7a7..98a233bd 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -9,10 +9,11 @@ import { UserDefinedOpenApiConfig, } from '../core/shared/types'; import { TypeScriptLoader } from 'cosmiconfig-typescript-loader'; -import { markdownOptions } from './commands/markdown'; -import { openApiOptions } from './commands/openapi'; -import { changeLogOptions } from './commands/changelog'; +import { markdownOptions, validateMarkdownArgs } from './commands/markdown'; +import { openApiOptions, validateOpenApiArgs } from './commands/openapi'; +import { changeLogOptions, validateChangelogArgs } from './commands/changelog'; import { pipe } from 'fp-ts/function'; +import { validateSourceDirectoryConfig } from '../util/source-directory-resolver'; const configOnlyMarkdownDefaults: Partial = { targetGenerator: 'markdown', @@ -91,11 +92,23 @@ function extractArgsForCommandProvidedThroughCli( switch (mergedConfig.targetGenerator) { case 'markdown': - return E.right({ ...configOnlyMarkdownDefaults, ...mergedConfig } as UserDefinedMarkdownConfig); + return pipe( + validateSourceDirectoryConfig(extractSourceDirectoryConfig(mergedConfig)), + E.mapLeft((error) => new Error(`Invalid markdown configuration: ${error.message}`)), + E.map(() => ({ ...configOnlyMarkdownDefaults, ...mergedConfig }) as UserDefinedMarkdownConfig), + ); case 'openapi': - return E.right({ ...configOnlyOpenApiDefaults, ...mergedConfig } as unknown as UserDefinedOpenApiConfig); + return pipe( + validateSourceDirectoryConfig(extractSourceDirectoryConfig(mergedConfig)), + E.mapLeft((error) => new Error(`Invalid openapi configuration: ${error.message}`)), + E.map(() => ({ ...configOnlyOpenApiDefaults, ...mergedConfig }) as unknown as UserDefinedOpenApiConfig), + ); case 'changelog': - return E.right({ ...configOnlyChangelogDefaults, ...mergedConfig } as unknown as UserDefinedChangelogConfig); + return pipe( + validateChangelogConfig(mergedConfig as unknown as UserDefinedChangelogConfig), + E.mapLeft((error) => new Error(`Invalid changelog configuration: ${error.message}`)), + E.map(() => ({ ...configOnlyChangelogDefaults, ...mergedConfig }) as unknown as UserDefinedChangelogConfig), + ); default: return E.left(new Error(`Invalid command provided: ${mergedConfig.targetGenerator}`)); } @@ -124,12 +137,26 @@ function extractArgsForCommandsProvidedInConfig( E.map((cliArgs) => { return cliArgs; }), - E.map((cliArgs) => ({ ...configOnlyMarkdownDefaults, ...generatorConfig, ...cliArgs })), + E.flatMap((cliArgs) => { + const mergedConfig = { ...configOnlyMarkdownDefaults, ...generatorConfig, ...cliArgs }; + return pipe( + validateSourceDirectoryConfig(extractSourceDirectoryConfig(mergedConfig)), + E.mapLeft((error) => new Error(`Invalid markdown configuration: ${error.message}`)), + E.map(() => mergedConfig), + ); + }), ); case 'openapi': return pipe( extractMultiCommandConfig(extractFromProcessFn, 'openapi', generatorConfig), - E.map((cliArgs) => ({ ...configOnlyOpenApiDefaults, ...generatorConfig, ...cliArgs })), + E.flatMap((cliArgs) => { + const mergedConfig = { ...configOnlyOpenApiDefaults, ...generatorConfig, ...cliArgs }; + return pipe( + validateSourceDirectoryConfig(extractSourceDirectoryConfig(mergedConfig)), + E.mapLeft((error) => new Error(`Invalid openapi configuration: ${error.message}`)), + E.map(() => mergedConfig), + ); + }), ); case 'changelog': return pipe( @@ -137,7 +164,14 @@ function extractArgsForCommandsProvidedInConfig( E.map((cliArgs) => { return cliArgs; }), - E.map((cliArgs) => ({ ...configOnlyChangelogDefaults, ...generatorConfig, ...cliArgs })), + E.flatMap((cliArgs) => { + const mergedConfig = { ...configOnlyChangelogDefaults, ...generatorConfig, ...cliArgs }; + return pipe( + validateChangelogConfig(mergedConfig as unknown as UserDefinedChangelogConfig), + E.mapLeft((error) => new Error(`Invalid changelog configuration: ${error.message}`)), + E.map(() => mergedConfig), + ); + }), ); } }); @@ -193,13 +227,13 @@ function extractYargsDemandingCommand(extractFromProcessFn: ExtractArgsFromProce return yargs .config(config.config as Record) .command('markdown', 'Generate documentation from Apex classes as a Markdown site.', (yargs) => - yargs.options(markdownOptions), + yargs.options(markdownOptions).check(validateMarkdownArgs), ) - .command('openapi', 'Generate an OpenApi REST specification from Apex classes.', () => - yargs.options(openApiOptions), + .command('openapi', 'Generate an OpenApi REST specification from Apex classes.', (yargs) => + yargs.options(openApiOptions).check(validateOpenApiArgs), ) - .command('changelog', 'Generate a changelog from 2 versions of the source code.', () => - yargs.options(changeLogOptions), + .command('changelog', 'Generate a changelog from 2 versions of the source code.', (yargs) => + yargs.options(changeLogOptions).check(validateChangelogArgs), ) .demandCommand() .parseSync(extractFromProcessFn()); @@ -221,14 +255,58 @@ function extractMultiCommandConfig( } } + function getValidationFunction(generator: Generators) { + switch (generator) { + case 'markdown': + return validateMarkdownArgs; + case 'openapi': + return validateOpenApiArgs; + case 'changelog': + return validateChangelogArgs; + } + } + const options = getOptions(command); + const validator = getValidationFunction(command); return E.tryCatch(() => { return yargs(extractFromProcessFn()) .config(config) .options(options) + .check(validator) .fail((msg) => { throw new Error(`Invalid configuration for command "${command}": ${msg}`); }) .parseSync(); }, E.toError); } + +function extractSourceDirectoryConfig(config: Record): { + sourceDir?: string | string[]; + useSfdxProjectJson?: boolean; + sfdxProjectPath?: string; +} { + return { + sourceDir: config.sourceDir as string | string[] | undefined, + useSfdxProjectJson: config.useSfdxProjectJson as boolean | undefined, + sfdxProjectPath: config.sfdxProjectPath as string | undefined, + }; +} + +function validateChangelogConfig( + config: UserDefinedChangelogConfig, +): E.Either<{ message: string }, UserDefinedChangelogConfig> { + const previousVersionConfig = { + sourceDir: config.previousVersionDir, + }; + + const currentVersionConfig = { + sourceDir: config.currentVersionDir, + }; + + return pipe( + E.Do, + E.bind('previousValid', () => validateSourceDirectoryConfig(previousVersionConfig)), + E.bind('currentValid', () => validateSourceDirectoryConfig(currentVersionConfig)), + E.map(() => config), + ); +} diff --git a/src/cli/commands/changelog.ts b/src/cli/commands/changelog.ts index b3ecaafb..0fa284fe 100644 --- a/src/cli/commands/changelog.ts +++ b/src/cli/commands/changelog.ts @@ -1,18 +1,47 @@ import { Options } from 'yargs'; import { changeLogDefaults } from '../../defaults'; +/** + * Custom validation function to ensure source directories are provided for both versions + */ +export function validateChangelogArgs(argv: Record): boolean { + const hasPreviousVersionDir = + argv.previousVersionDir && + (typeof argv.previousVersionDir === 'string' || + (Array.isArray(argv.previousVersionDir) && argv.previousVersionDir.length > 0)); + + const hasCurrentVersionDir = + argv.currentVersionDir && + (typeof argv.currentVersionDir === 'string' || + (Array.isArray(argv.currentVersionDir) && argv.currentVersionDir.length > 0)); + + if (!hasPreviousVersionDir) { + throw new Error('Must specify --previousVersionDir'); + } + + if (!hasCurrentVersionDir) { + throw new Error('Must specify --currentVersionDir'); + } + + return true; +} + export const changeLogOptions: { [key: string]: Options } = { previousVersionDir: { type: 'string', + array: true, alias: 'p', - demandOption: true, - describe: 'The directory location of the previous version of the source code.', + demandOption: false, + describe: + 'The directory location(s) of the previous version of the source code. Can specify a single directory or multiple directories.', }, currentVersionDir: { type: 'string', + array: true, alias: 'c', - demandOption: true, - describe: 'The directory location of the current version of the source code.', + demandOption: false, + describe: + 'The directory location(s) of the current version of the source code. Can specify a single directory or multiple directories.', }, targetDir: { type: 'string', diff --git a/src/cli/commands/markdown.ts b/src/cli/commands/markdown.ts index e52c1cdd..ee1bc1e4 100644 --- a/src/cli/commands/markdown.ts +++ b/src/cli/commands/markdown.ts @@ -2,12 +2,44 @@ import { Options } from 'yargs'; import { markdownDefaults } from '../../defaults'; import { CliConfigurableMarkdownConfig } from '../../core/shared/types'; +/** + * Custom validation function to ensure at least one source directory method is provided + */ +export function validateMarkdownArgs(argv: Record): boolean { + const hasSourceDir = + argv.sourceDir && + (typeof argv.sourceDir === 'string' || (Array.isArray(argv.sourceDir) && argv.sourceDir.length > 0)); + const hasUseSfdxProjectJson = argv.useSfdxProjectJson; + + if (!hasSourceDir && !hasUseSfdxProjectJson) { + throw new Error('Must specify one of: --sourceDir or --useSfdxProjectJson'); + } + + return true; +} + export const markdownOptions: Record = { sourceDir: { type: 'string', + array: true, alias: 's', - demandOption: true, - describe: 'The directory location which contains your apex .cls classes.', + demandOption: false, + describe: + 'The directory location(s) which contain your apex .cls classes. Can specify a single directory or multiple directories. Cannot be used with useSfdxProjectJson.', + conflicts: ['useSfdxProjectJson'], + }, + useSfdxProjectJson: { + type: 'boolean', + demandOption: false, + describe: 'Read source directories from sfdx-project.json packageDirectories. Cannot be used with sourceDir.', + conflicts: ['sourceDir'], + }, + sfdxProjectPath: { + type: 'string', + demandOption: false, + describe: + 'Path to the directory containing sfdx-project.json (defaults to current working directory). Only used with useSfdxProjectJson.', + implies: 'useSfdxProjectJson', }, targetDir: { type: 'string', @@ -74,7 +106,8 @@ export const markdownOptions: Record): boolean { + const hasSourceDir = + argv.sourceDir && + (typeof argv.sourceDir === 'string' || (Array.isArray(argv.sourceDir) && argv.sourceDir.length > 0)); + const hasUseSfdxProjectJson = argv.useSfdxProjectJson; + + if (!hasSourceDir && !hasUseSfdxProjectJson) { + throw new Error('Must specify one of: --sourceDir or --useSfdxProjectJson'); + } + + return true; +} + export const openApiOptions: { [key: string]: Options } = { sourceDir: { type: 'string', + array: true, alias: 's', - demandOption: true, - describe: 'The directory location which contains your apex .cls classes.', + demandOption: false, + describe: + 'The directory location(s) which contain your apex .cls classes. Can specify a single directory or multiple directories. Cannot be used with useSfdxProjectJson.', + conflicts: ['useSfdxProjectJson'], + }, + useSfdxProjectJson: { + type: 'boolean', + demandOption: false, + describe: 'Read source directories from sfdx-project.json packageDirectories. Cannot be used with sourceDir.', + conflicts: ['sourceDir'], + }, + sfdxProjectPath: { + type: 'string', + demandOption: false, + describe: + 'Path to the directory containing sfdx-project.json (defaults to current working directory). Only used with useSfdxProjectJson.', + implies: 'useSfdxProjectJson', }, targetDir: { type: 'string', diff --git a/src/core/shared/types.d.ts b/src/core/shared/types.d.ts index 5ff3823c..cd4340f4 100644 --- a/src/core/shared/types.d.ts +++ b/src/core/shared/types.d.ts @@ -26,7 +26,9 @@ export type MacroSourceMetadata = { export type MacroFunction = (metadata: MacroSourceMetadata) => string; export type CliConfigurableMarkdownConfig = { - sourceDir: string; + sourceDir?: string | string[]; + useSfdxProjectJson?: boolean; + sfdxProjectPath?: string; targetDir: string; scope: string[]; customObjectVisibility: string[]; @@ -51,7 +53,9 @@ export type UserDefinedMarkdownConfig = { export type UserDefinedOpenApiConfig = { targetGenerator: 'openapi'; - sourceDir: string; + sourceDir?: string | string[]; + useSfdxProjectJson?: boolean; + sfdxProjectPath?: string; targetDir: string; fileName: string; namespace?: string; @@ -62,8 +66,8 @@ export type UserDefinedOpenApiConfig = { export type UserDefinedChangelogConfig = { targetGenerator: 'changelog'; - previousVersionDir: string; - currentVersionDir: string; + previousVersionDir?: string | string[]; + currentVersionDir?: string | string[]; targetDir: string; fileName: string; scope: string[]; diff --git a/src/util/__tests__/sfdx-project-reader.spec.ts b/src/util/__tests__/sfdx-project-reader.spec.ts new file mode 100644 index 00000000..f01b660b --- /dev/null +++ b/src/util/__tests__/sfdx-project-reader.spec.ts @@ -0,0 +1,332 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as E from 'fp-ts/Either'; +import { readSfdxProjectConfig, getSfdxSourceDirectories, getSfdxDefaultSourceDirectory } from '../sfdx-project-reader'; + +// Mock fs module +jest.mock('fs'); +const mockFs = fs as jest.Mocked; + +describe('sfdx-project-reader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('readSfdxProjectConfig', () => { + it('should read and parse a valid sfdx-project.json file', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'force-app', default: true }, + { path: 'force-lwc', default: false }, + ], + }); + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = readSfdxProjectConfig(projectRoot); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.packageDirectories).toHaveLength(2); + expect(result.right.packageDirectories[0].path).toBe('force-app'); + expect(result.right.packageDirectories[0].default).toBe(true); + expect(result.right.packageDirectories[1].path).toBe('force-lwc'); + expect(result.right.packageDirectories[1].default).toBe(false); + } + + expect(mockFs.existsSync).toHaveBeenCalledWith(path.join(projectRoot, 'sfdx-project.json')); + expect(mockFs.readFileSync).toHaveBeenCalledWith(path.join(projectRoot, 'sfdx-project.json'), 'utf8'); + }); + + it('should return an error when sfdx-project.json does not exist', () => { + const projectRoot = '/test/project'; + + mockFs.existsSync.mockReturnValue(false); + + const result = readSfdxProjectConfig(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('Failed to read sfdx-project.json'); + expect(result.left.message).toContain('not found'); + } + }); + + it('should return an error when sfdx-project.json contains invalid JSON', () => { + const projectRoot = '/test/project'; + const invalidJson = '{ "packageDirectories": [ invalid json }'; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(invalidJson); + + const result = readSfdxProjectConfig(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('Failed to parse sfdx-project.json'); + } + }); + + it('should return an error when packageDirectories is missing', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + someOtherProperty: 'value', + }); + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = readSfdxProjectConfig(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('does not contain a valid packageDirectories array'); + } + }); + + it('should return an error when packageDirectories is not an array', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: 'not-an-array', + }); + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = readSfdxProjectConfig(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('does not contain a valid packageDirectories array'); + } + }); + }); + + describe('getSfdxSourceDirectories', () => { + it('should return absolute paths by default', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'force-app', default: true }, + { path: 'force-lwc', default: false }, + { path: 'force-obj', default: false }, + ], + }); + + mockFs.existsSync.mockImplementation((filePath: fs.PathLike) => { + const pathStr = filePath.toString(); + return ( + pathStr.endsWith('sfdx-project.json') || + pathStr.endsWith('force-app') || + pathStr.endsWith('force-lwc') || + pathStr.endsWith('force-obj') + ); + }); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxSourceDirectories(projectRoot); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toHaveLength(3); + expect(result.right[0]).toBe(path.resolve(projectRoot, 'force-app')); + expect(result.right[1]).toBe(path.resolve(projectRoot, 'force-lwc')); + expect(result.right[2]).toBe(path.resolve(projectRoot, 'force-obj')); + } + }); + + it('should return relative paths when absolutePaths is false', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'force-app', default: true }, + { path: 'force-lwc', default: false }, + ], + }); + + mockFs.existsSync.mockImplementation((filePath: fs.PathLike) => { + const pathStr = filePath.toString(); + return pathStr.endsWith('sfdx-project.json') || pathStr.includes('force-'); + }); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxSourceDirectories(projectRoot, false); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toHaveLength(2); + expect(result.right[0]).toBe('force-app'); + expect(result.right[1]).toBe('force-lwc'); + } + }); + + it('should return an error when a package directory does not exist', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'force-app', default: true }, + { path: 'non-existent-dir', default: false }, + ], + }); + + mockFs.existsSync.mockImplementation((filePath: fs.PathLike) => { + const pathStr = filePath.toString(); + return pathStr.endsWith('sfdx-project.json') || pathStr.endsWith('force-app'); + }); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxSourceDirectories(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('package directories do not exist'); + expect(result.left.message).toContain('non-existent-dir'); + } + }); + + it('should propagate errors from reading sfdx-project.json', () => { + const projectRoot = '/test/project'; + + mockFs.existsSync.mockReturnValue(false); + + const result = getSfdxSourceDirectories(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('Failed to read sfdx-project.json'); + } + }); + }); + + describe('getSfdxDefaultSourceDirectory', () => { + it('should return the default directory when one is specified', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'force-app', default: true }, + { path: 'force-lwc', default: false }, + ], + }); + + mockFs.existsSync.mockImplementation((filePath: fs.PathLike) => { + const pathStr = filePath.toString(); + return pathStr.endsWith('sfdx-project.json') || pathStr.endsWith('force-app'); + }); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxDefaultSourceDirectory(projectRoot); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe(path.resolve(projectRoot, 'force-app')); + } + }); + + it('should return relative path when absolutePath is false', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'force-app', default: true }, + { path: 'force-lwc', default: false }, + ], + }); + + mockFs.existsSync.mockImplementation((filePath: fs.PathLike) => { + const pathStr = filePath.toString(); + return pathStr.endsWith('sfdx-project.json') || pathStr.endsWith('force-app'); + }); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxDefaultSourceDirectory(projectRoot, false); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe('force-app'); + } + }); + + it('should return undefined when no default directory is specified', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'force-app', default: false }, + { path: 'force-lwc', default: false }, + ], + }); + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxDefaultSourceDirectory(projectRoot); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBeUndefined(); + } + }); + + it('should return undefined when no default property is specified on any directory', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [{ path: 'force-app' }, { path: 'force-lwc' }], + }); + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxDefaultSourceDirectory(projectRoot); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBeUndefined(); + } + }); + + it('should return an error when the default directory does not exist', () => { + const projectRoot = '/test/project'; + const sfdxProjectContent = JSON.stringify({ + packageDirectories: [ + { path: 'non-existent-dir', default: true }, + { path: 'force-lwc', default: false }, + ], + }); + + mockFs.existsSync.mockImplementation((filePath: fs.PathLike) => { + const pathStr = filePath.toString(); + return pathStr.endsWith('sfdx-project.json'); + }); + mockFs.readFileSync.mockReturnValue(sfdxProjectContent); + + const result = getSfdxDefaultSourceDirectory(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('Default package directory does not exist'); + expect(result.left.message).toContain('non-existent-dir'); + } + }); + + it('should propagate errors from reading sfdx-project.json', () => { + const projectRoot = '/test/project'; + + mockFs.existsSync.mockReturnValue(false); + + const result = getSfdxDefaultSourceDirectory(projectRoot); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SfdxProjectReadError'); + expect(result.left.message).toContain('Failed to read sfdx-project.json'); + } + }); + }); +}); diff --git a/src/util/__tests__/source-directory-resolver.spec.ts b/src/util/__tests__/source-directory-resolver.spec.ts new file mode 100644 index 00000000..4240421d --- /dev/null +++ b/src/util/__tests__/source-directory-resolver.spec.ts @@ -0,0 +1,328 @@ +import * as E from 'fp-ts/Either'; +import { + resolveSourceDirectories, + validateSourceDirectoryConfig, + resolveAndValidateSourceDirectories, + SourceDirectoryConfig, +} from '../source-directory-resolver'; +import { getSfdxSourceDirectories } from '../sfdx-project-reader'; + +// Mock the sfdx-project-reader module +jest.mock('../sfdx-project-reader'); +const mockGetSfdxSourceDirectories = getSfdxSourceDirectories as jest.MockedFunction; + +describe('source-directory-resolver', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Mock process.cwd() + jest.spyOn(process, 'cwd').mockReturnValue('/current/working/directory'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resolveSourceDirectories', () => { + it('should resolve a single source directory', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + }; + + const result = resolveSourceDirectories(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(['force-app']); + } + }); + + it('should resolve multiple source directories', () => { + const config: SourceDirectoryConfig = { + sourceDir: ['force-app', 'force-lwc', 'force-obj'], + }; + + const result = resolveSourceDirectories(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(['force-app', 'force-lwc', 'force-obj']); + } + }); + + it('should resolve directories from sfdx-project.json using current working directory', () => { + const config: SourceDirectoryConfig = { + useSfdxProjectJson: true, + }; + + const mockDirectories = ['/project/force-app', '/project/force-lwc']; + mockGetSfdxSourceDirectories.mockReturnValue(E.right(mockDirectories)); + + const result = resolveSourceDirectories(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(mockDirectories); + } + expect(mockGetSfdxSourceDirectories).toHaveBeenCalledWith('/current/working/directory'); + }); + + it('should resolve directories from sfdx-project.json using specified path', () => { + const config: SourceDirectoryConfig = { + useSfdxProjectJson: true, + sfdxProjectPath: '/custom/project/path', + }; + + const mockDirectories = ['/custom/project/path/force-app', '/custom/project/path/force-lwc']; + mockGetSfdxSourceDirectories.mockReturnValue(E.right(mockDirectories)); + + const result = resolveSourceDirectories(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(mockDirectories); + } + expect(mockGetSfdxSourceDirectories).toHaveBeenCalledWith('/custom/project/path'); + }); + + it('should return an error when no source directory method is specified', () => { + const config: SourceDirectoryConfig = {}; + + const result = resolveSourceDirectories(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('No source directory method specified'); + } + }); + + it('should return an error when multiple source directory methods are specified', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + useSfdxProjectJson: true, + }; + + const result = resolveSourceDirectories(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('Multiple source directory methods specified'); + } + }); + + it('should return an error when sourceDir and useSfdxProjectJson are both specified', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + useSfdxProjectJson: true, + }; + + const result = resolveSourceDirectories(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('Multiple source directory methods specified'); + } + }); + + it('should handle empty sourceDir array', () => { + const config: SourceDirectoryConfig = { + sourceDir: [], + }; + + const result = resolveSourceDirectories(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('No source directory method specified'); + } + }); + + it('should propagate errors from sfdx-project.json reading', () => { + const config: SourceDirectoryConfig = { + useSfdxProjectJson: true, + }; + + const mockError = { + _tag: 'SfdxProjectReadError' as const, + message: 'Failed to read sfdx-project.json', + }; + mockGetSfdxSourceDirectories.mockReturnValue(E.left(mockError)); + + const result = resolveSourceDirectories(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('Failed to read source directories from sfdx-project.json'); + expect(result.left.message).toContain('Failed to read sfdx-project.json'); + expect(result.left.cause).toBe(mockError); + } + }); + }); + + describe('validateSourceDirectoryConfig', () => { + it('should validate a valid single source directory config', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + }; + + const result = validateSourceDirectoryConfig(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(config); + } + }); + + it('should validate a valid multiple source directories config', () => { + const config: SourceDirectoryConfig = { + sourceDir: ['force-app', 'force-lwc'], + }; + + const result = validateSourceDirectoryConfig(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(config); + } + }); + + it('should validate a valid sfdx-project.json config', () => { + const config: SourceDirectoryConfig = { + useSfdxProjectJson: true, + sfdxProjectPath: '/custom/path', + }; + + const result = validateSourceDirectoryConfig(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(config); + } + }); + + it('should return an error when sourceDir and useSfdxProjectJson are both specified', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + useSfdxProjectJson: true, + }; + + const result = validateSourceDirectoryConfig(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('Cannot specify both sourceDir and useSfdxProjectJson'); + } + }); + + it('should return an error when sfdxProjectPath is specified without useSfdxProjectJson', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + sfdxProjectPath: '/path/to/project', + }; + + const result = validateSourceDirectoryConfig(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('sfdxProjectPath can only be used with useSfdxProjectJson'); + } + }); + + it('should return an error when sourceDir is an empty array', () => { + const config: SourceDirectoryConfig = { + sourceDir: [], + }; + + const result = validateSourceDirectoryConfig(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('sourceDir array cannot be empty'); + } + }); + + it('should validate an empty config', () => { + const config: SourceDirectoryConfig = {}; + + const result = validateSourceDirectoryConfig(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(config); + } + }); + }); + + describe('resolveAndValidateSourceDirectories', () => { + it('should successfully resolve and validate a valid config', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + }; + + const result = resolveAndValidateSourceDirectories(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(['force-app']); + } + }); + + it('should return validation errors first', () => { + const config: SourceDirectoryConfig = { + sourceDir: 'force-app', + useSfdxProjectJson: true, + }; + + const result = resolveAndValidateSourceDirectories(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('Cannot specify both sourceDir and useSfdxProjectJson'); + } + }); + + it('should return resolution errors when validation passes but resolution fails', () => { + const config: SourceDirectoryConfig = { + useSfdxProjectJson: true, + }; + + const mockError = { + _tag: 'SfdxProjectReadError' as const, + message: 'Failed to read sfdx-project.json', + }; + mockGetSfdxSourceDirectories.mockReturnValue(E.left(mockError)); + + const result = resolveAndValidateSourceDirectories(config); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left._tag).toBe('SourceDirectoryResolutionError'); + expect(result.left.message).toContain('Failed to read source directories from sfdx-project.json'); + } + }); + + it('should handle successful sfdx-project.json resolution', () => { + const config: SourceDirectoryConfig = { + useSfdxProjectJson: true, + }; + + const mockDirectories = ['/project/force-app', '/project/force-lwc']; + mockGetSfdxSourceDirectories.mockReturnValue(E.right(mockDirectories)); + + const result = resolveAndValidateSourceDirectories(config); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toEqual(mockDirectories); + } + }); + }); +}); diff --git a/src/util/sfdx-project-reader.ts b/src/util/sfdx-project-reader.ts new file mode 100644 index 00000000..128d8565 --- /dev/null +++ b/src/util/sfdx-project-reader.ts @@ -0,0 +1,125 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as E from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; + +export type SfdxProjectConfig = { + packageDirectories: Array<{ + path: string; + default?: boolean; + }>; +}; + +export type SfdxProjectReadError = { + readonly _tag: 'SfdxProjectReadError'; + readonly message: string; + readonly cause?: unknown; +}; + +/** + * Creates an SfdxProjectReadError + */ +function createSfdxProjectReadError(message: string, cause?: unknown): SfdxProjectReadError { + return { + _tag: 'SfdxProjectReadError', + message, + cause, + }; +} + +/** + * Reads and parses the sfdx-project.json file from the given directory + * @param projectRoot - The root directory where sfdx-project.json should be located + * @returns Either an error or the parsed sfdx-project.json configuration + */ +export function readSfdxProjectConfig(projectRoot: string): E.Either { + const sfdxProjectPath = path.join(projectRoot, 'sfdx-project.json'); + + return pipe( + E.tryCatch( + () => { + if (!fs.existsSync(sfdxProjectPath)) { + throw new Error(`sfdx-project.json not found at ${sfdxProjectPath}`); + } + return fs.readFileSync(sfdxProjectPath, 'utf8'); + }, + (error) => createSfdxProjectReadError(`Failed to read sfdx-project.json: ${error}`, error), + ), + E.flatMap((content) => + E.tryCatch( + () => JSON.parse(content) as SfdxProjectConfig, + (error) => createSfdxProjectReadError(`Failed to parse sfdx-project.json: ${error}`, error), + ), + ), + E.flatMap((config) => { + if (!config.packageDirectories || !Array.isArray(config.packageDirectories)) { + return E.left( + createSfdxProjectReadError('sfdx-project.json does not contain a valid packageDirectories array'), + ); + } + return E.right(config); + }), + ); +} + +/** + * Extracts the source directory paths from the sfdx-project.json configuration + * @param projectRoot - The root directory where sfdx-project.json is located + * @param absolutePaths - Whether to return absolute paths (default: true) + * @returns Either an error or an array of directory paths + */ +export function getSfdxSourceDirectories( + projectRoot: string, + absolutePaths: boolean = true, +): E.Either { + return pipe( + readSfdxProjectConfig(projectRoot), + E.map((config) => config.packageDirectories.map((dir) => dir.path)), + E.map((paths) => { + if (absolutePaths) { + return paths.map((dirPath) => path.resolve(projectRoot, dirPath)); + } + return paths; + }), + E.flatMap((paths) => { + // Validate that all paths exist + const nonExistentPaths = paths.filter((dirPath) => !fs.existsSync(dirPath)); + if (nonExistentPaths.length > 0) { + return E.left( + createSfdxProjectReadError( + `The following package directories do not exist: ${nonExistentPaths.join(', ')}`, + ), + ); + } + return E.right(paths); + }), + ); +} + +/** + * Gets the default source directory from the sfdx-project.json configuration + * @param projectRoot - The root directory where sfdx-project.json is located + * @param absolutePath - Whether to return an absolute path (default: true) + * @returns Either an error or the default directory path, or undefined if no default is specified + */ +export function getSfdxDefaultSourceDirectory( + projectRoot: string, + absolutePath: boolean = true, +): E.Either { + return pipe( + readSfdxProjectConfig(projectRoot), + E.map((config) => { + const defaultDir = config.packageDirectories.find((dir) => dir.default === true); + if (!defaultDir) { + return undefined; + } + return absolutePath ? path.resolve(projectRoot, defaultDir.path) : defaultDir.path; + }), + E.flatMap((defaultPath) => { + if (defaultPath && !fs.existsSync(defaultPath)) { + return E.left(createSfdxProjectReadError(`Default package directory does not exist: ${defaultPath}`)); + } + return E.right(defaultPath); + }), + ); +} diff --git a/src/util/source-directory-resolver.ts b/src/util/source-directory-resolver.ts new file mode 100644 index 00000000..de54cfea --- /dev/null +++ b/src/util/source-directory-resolver.ts @@ -0,0 +1,125 @@ +import * as E from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; +import { getSfdxSourceDirectories } from './sfdx-project-reader'; + +export type SourceDirectoryResolutionError = { + readonly _tag: 'SourceDirectoryResolutionError'; + readonly message: string; + readonly cause?: unknown; +}; + +/** + * Creates a SourceDirectoryResolutionError + */ +function createSourceDirectoryResolutionError(message: string, cause?: unknown): SourceDirectoryResolutionError { + return { + _tag: 'SourceDirectoryResolutionError', + message, + cause, + }; +} + +export type SourceDirectoryConfig = { + sourceDir?: string | string[]; + useSfdxProjectJson?: boolean; + sfdxProjectPath?: string; +}; + +/** + * Resolves source directories from various configuration options + * @param config - The source directory configuration + * @returns Either an error or an array of resolved source directory paths + */ +export function resolveSourceDirectories( + config: SourceDirectoryConfig, +): E.Either { + const { sourceDir, useSfdxProjectJson, sfdxProjectPath } = config; + + // Count how many source directory methods are specified + const hasSourceDir = + sourceDir && (typeof sourceDir === 'string' || (Array.isArray(sourceDir) && sourceDir.length > 0)); + const methodsSpecified = [hasSourceDir, useSfdxProjectJson].filter(Boolean).length; + + if (methodsSpecified === 0) { + return E.left( + createSourceDirectoryResolutionError( + 'No source directory method specified. Must provide one of: sourceDir or useSfdxProjectJson.', + ), + ); + } + + if (methodsSpecified > 1) { + return E.left( + createSourceDirectoryResolutionError( + 'Multiple source directory methods specified. Only one of sourceDir or useSfdxProjectJson can be used.', + ), + ); + } + + // Handle source directory (single or multiple) + if (sourceDir) { + if (typeof sourceDir === 'string') { + return E.right([sourceDir]); + } else if (Array.isArray(sourceDir)) { + return E.right(sourceDir); + } + } + + // Handle sfdx-project.json + if (useSfdxProjectJson) { + const projectPath = sfdxProjectPath || process.cwd(); + return pipe( + getSfdxSourceDirectories(projectPath), + E.mapLeft((sfdxError) => + createSourceDirectoryResolutionError( + `Failed to read source directories from sfdx-project.json: ${sfdxError.message}`, + sfdxError, + ), + ), + ); + } + + return E.left(createSourceDirectoryResolutionError('Invalid source directory configuration.')); +} + +/** + * Validates that the provided source directory configuration is valid + * @param config - The source directory configuration to validate + * @returns Either an error or the validated configuration + */ +export function validateSourceDirectoryConfig( + config: SourceDirectoryConfig, +): E.Either { + const { sourceDir, useSfdxProjectJson, sfdxProjectPath } = config; + + // Check for conflicting options + if (sourceDir && useSfdxProjectJson) { + return E.left( + createSourceDirectoryResolutionError('Cannot specify both sourceDir and useSfdxProjectJson. Use only one.'), + ); + } + + // Check for invalid combinations + if (sfdxProjectPath && !useSfdxProjectJson) { + return E.left(createSourceDirectoryResolutionError('sfdxProjectPath can only be used with useSfdxProjectJson.')); + } + + if (Array.isArray(sourceDir) && sourceDir.length === 0) { + return E.left( + createSourceDirectoryResolutionError('sourceDir array cannot be empty. Provide at least one directory.'), + ); + } + + return E.right(config); +} + +/** + * Convenience function for resolving source directories with validation + * @param config - The source directory configuration + * @returns Either an error or an array of resolved source directory paths + */ +export function resolveAndValidateSourceDirectories( + config: SourceDirectoryConfig, +): E.Either { + return pipe(validateSourceDirectoryConfig(config), E.flatMap(resolveSourceDirectories)); +}