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));
+}