├── tests ├── bootstrap.php ├── DocudoodleTest.php ├── AzureOpenAITest.php └── CachingTest.php ├── docudoodle.png ├── composer.lock ├── phpunit.xml ├── src ├── DocudoodleServiceProvider.php ├── Services │ ├── ConfluenceDocumentationService.php │ └── JiraDocumentationService.php ├── Commands │ └── GenerateDocsCommand.php └── Docudoodle.php ├── composer.json ├── LICENSE ├── resources └── templates │ └── default-prompt.md ├── examples ├── RouteServiceProvider.md └── BroadcastServiceProvider.md ├── config └── docudoodle.php └── README.md /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ./tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ./src 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/DocudoodleServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 18 | $this->commands([ 19 | GenerateDocsCommand::class, 20 | ]); 21 | 22 | $this->publishes([ 23 | __DIR__.'/../config/docudoodle.php' => config_path('docudoodle.php'), 24 | ], 'docudoodle-config'); 25 | } 26 | } 27 | 28 | /** 29 | * Register the application services. 30 | * 31 | * @return void 32 | */ 33 | public function register() 34 | { 35 | $this->mergeConfigFrom( 36 | __DIR__.'/../config/docudoodle.php', 'docudoodle' 37 | ); 38 | } 39 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "genericmilk/docudoodle", 3 | "description": "Generate documentation for your Laravel application using OpenAI", 4 | "type": "library", 5 | "version": "2.2.0", 6 | "require": { 7 | "php": "^7.3|^8.0", 8 | "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0", 9 | "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0" 10 | }, 11 | "autoload": { 12 | "psr-4": { 13 | "Docudoodle\\": "src/" 14 | } 15 | }, 16 | "extra": { 17 | "laravel": { 18 | "providers": [ 19 | "Docudoodle\\DocudoodleServiceProvider" 20 | ] 21 | } 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^9.5", 25 | "php-mock/php-mock-phpunit": "^2.13" 26 | }, 27 | "minimum-stability": "dev", 28 | "prefer-stable": true, 29 | "license": "MIT", 30 | "authors": [ 31 | { 32 | "name": "Peter Day", 33 | "email": "peterday.main@gmail.com" 34 | } 35 | ], 36 | "homepage": "https://github.com/genericmilk/docudoodle", 37 | "keywords": [ 38 | "documentation", 39 | "php", 40 | "openai", 41 | "generator" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 [Your Name or Organization] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 2. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /resources/templates/default-prompt.md: -------------------------------------------------------------------------------- 1 | # Documentation Prompt Template 2 | 3 | You are documenting a PHP codebase. Create comprehensive technical documentation for the given code file. 4 | 5 | File: {FILE_PATH} 6 | 7 | Content: 8 | 9 | ``` 10 | {FILE_CONTENT} 11 | ``` 12 | 13 | Create detailed markdown documentation following this structure: 14 | 15 | 1. Start with a descriptive title that includes the file name (e.g., "# [ClassName] Documentation") 16 | 2. Include a table of contents with links to each section when appropriate. Use normalized links (e.g., `#method-name`). 17 | 3. Create an introduction section that explains the purpose and role of this file in the system 18 | 4. For each major method or function: 19 | - Document its purpose 20 | - Explain its parameters and return values 21 | - Describe its functionality in detail 22 | 5. Use appropriate markdown formatting: 23 | - Code blocks with appropriate syntax highlighting 24 | - Tables for structured information 25 | - Lists for enumerated items 26 | - Headers for proper section hierarchy 27 | 6. Include technical details but explain them clearly 28 | 7. For controller classes, document the routes they handle 29 | 8. For models, document their relationships and important attributes 30 | 9. Include the following diagrams when applicable: 31 | - **Workflow Chart**: Visualize the overall process or flow within the file. 32 | - **Use Case Diagram**: Illustrate the main actors and their interactions with the system. 33 | - **Sequence Diagram**: Show the sequence of method or function calls for key operations. 34 | - **Class Diagram**: Depict the classes, their attributes, methods, and relationships. 35 | 36 | Focus on accuracy and comprehensiveness. Your documentation should help developers understand both how the code 37 | works and why it exists. 38 | -------------------------------------------------------------------------------- /tests/DocudoodleTest.php: -------------------------------------------------------------------------------- 1 | generator = new DocumentationGenerator("sk-XXXXXXXX"); 12 | } 13 | 14 | public function testEnsureDirectoryExists() 15 | { 16 | $testDir = 'test_directory'; 17 | $this->generator->ensureDirectoryExists($testDir); 18 | $this->assertTrue(file_exists($testDir)); 19 | rmdir($testDir); // Clean up 20 | } 21 | 22 | public function testGetFileExtension() 23 | { 24 | $this->assertEquals('php', $this->generator->getFileExtension('example.php')); 25 | $this->assertEquals('yaml', $this->generator->getFileExtension('example.yaml')); 26 | } 27 | 28 | public function testShouldProcessFile() 29 | { 30 | $this->assertTrue($this->generator->shouldProcessFile('example.php')); 31 | $this->assertFalse($this->generator->shouldProcessFile('.hidden.php')); 32 | $this->assertFalse($this->generator->shouldProcessFile('example.txt')); 33 | } 34 | 35 | public function testShouldProcessDirectory() 36 | { 37 | $this->assertTrue($this->generator->shouldProcessDirectory('src')); 38 | $this->assertFalse($this->generator->shouldProcessDirectory('vendor')); 39 | } 40 | 41 | public function testReadFileContent() 42 | { 43 | file_put_contents('test_file.php', 'generator->readFileContent('test_file.php'); 45 | $this->assertStringContainsString('Hello World', $content); 46 | unlink('test_file.php'); // Clean up 47 | } 48 | 49 | public function testGenerateDocumentation() 50 | { 51 | $content = 'generator->generateDocumentation('test_file.php', $content); 53 | $this->assertStringContainsString('# Documentation: test_file.php', $docContent); 54 | } 55 | 56 | public function testCreateDocumentationFile() 57 | { 58 | $sourcePath = 'test_file.php'; 59 | $relPath = 'test_file.md'; 60 | file_put_contents($sourcePath, 'generator->createDocumentationFile($sourcePath, $relPath); 63 | $this->assertTrue(file_exists('documentation/test_file.md')); 64 | 65 | unlink($sourcePath); // Clean up 66 | unlink('documentation/test_file.md'); // Clean up 67 | } 68 | } -------------------------------------------------------------------------------- /tests/AzureOpenAITest.php: -------------------------------------------------------------------------------- 1 | assertIsObject($docudoodle); 26 | } 27 | 28 | public function testAzureConfigurationValuesAreUsedProperly() 29 | { 30 | // This is a reflection-based test to check that the properties are set correctly 31 | 32 | // Arrange 33 | $azureEndpoint = 'https://test-resource.openai.azure.com'; 34 | $azureDeployment = 'test-deployment'; 35 | $azureApiVersion = '2023-07-01'; // Custom version 36 | 37 | // Act 38 | $docudoodle = new Docudoodle( 39 | openaiApiKey: 'test-key', 40 | apiProvider: 'azure', 41 | azureEndpoint: $azureEndpoint, 42 | azureDeployment: $azureDeployment, 43 | azureApiVersion: $azureApiVersion 44 | ); 45 | 46 | // Use reflection to access private properties 47 | $reflector = new ReflectionClass($docudoodle); 48 | 49 | $endpointProperty = $reflector->getProperty('azureEndpoint'); 50 | $endpointProperty->setAccessible(true); 51 | 52 | $deploymentProperty = $reflector->getProperty('azureDeployment'); 53 | $deploymentProperty->setAccessible(true); 54 | 55 | $versionProperty = $reflector->getProperty('azureApiVersion'); 56 | $versionProperty->setAccessible(true); 57 | 58 | $providerProperty = $reflector->getProperty('apiProvider'); 59 | $providerProperty->setAccessible(true); 60 | 61 | // Assert 62 | $this->assertEquals($azureEndpoint, $endpointProperty->getValue($docudoodle)); 63 | $this->assertEquals($azureDeployment, $deploymentProperty->getValue($docudoodle)); 64 | $this->assertEquals($azureApiVersion, $versionProperty->getValue($docudoodle)); 65 | $this->assertEquals('azure', $providerProperty->getValue($docudoodle)); 66 | } 67 | } -------------------------------------------------------------------------------- /src/Services/ConfluenceDocumentationService.php: -------------------------------------------------------------------------------- 1 | config = $config; 16 | $this->client = new Client([ 17 | 'base_uri' => rtrim($config['host'], '/') . '/wiki/rest/api/', 18 | 'auth' => [$config['email'], $config['api_token']], 19 | 'headers' => [ 20 | 'Accept' => 'application/json', 21 | 'Content-Type' => 'application/json', 22 | ], 23 | ]); 24 | } 25 | 26 | public function createOrUpdatePage(string $title, string $content, array $metadata = []): bool 27 | { 28 | try { 29 | // Search for existing page with this title 30 | $response = $this->client->get('content', [ 31 | 'query' => [ 32 | 'spaceKey' => $this->config['space_key'], 33 | 'title' => $title, 34 | 'expand' => 'version' 35 | ] 36 | ]); 37 | 38 | $result = json_decode($response->getBody(), true); 39 | $pageId = !empty($result['results']) ? $result['results'][0]['id'] : null; 40 | $version = !empty($result['results']) ? $result['results'][0]['version']['number'] : 0; 41 | 42 | $pageData = [ 43 | 'type' => 'page', 44 | 'title' => $title, 45 | 'space' => ['key' => $this->config['space_key']], 46 | 'body' => [ 47 | 'storage' => [ 48 | 'value' => $content, 49 | 'representation' => 'storage' 50 | ] 51 | ], 52 | ]; 53 | 54 | if ($this->config['parent_page_id']) { 55 | $pageData['ancestors'] = [['id' => $this->config['parent_page_id']]]; 56 | } 57 | 58 | if ($pageId) { 59 | // Update existing page 60 | $pageData['version'] = ['number' => $version + 1]; 61 | $this->client->put("content/{$pageId}", [ 62 | 'json' => $pageData 63 | ]); 64 | } else { 65 | // Create new page 66 | $this->client->post('content', [ 67 | 'json' => $pageData 68 | ]); 69 | } 70 | 71 | return true; 72 | } catch (Exception $e) { 73 | // Log error or handle it as needed 74 | return false; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Services/JiraDocumentationService.php: -------------------------------------------------------------------------------- 1 | config = $config; 16 | $this->client = new Client([ 17 | 'base_uri' => rtrim($config['host'], '/') . '/rest/api/3/', 18 | 'auth' => [$config['email'], $config['api_token']], 19 | 'headers' => [ 20 | 'Accept' => 'application/json', 21 | 'Content-Type' => 'application/json', 22 | ], 23 | ]); 24 | } 25 | 26 | public function createOrUpdateDocumentation(string $title, string $content, array $metadata = []): bool 27 | { 28 | try { 29 | // Search for existing documentation with this title 30 | $jql = sprintf( 31 | 'project = "%s" AND issuetype = "%s" AND summary ~ "%s"', 32 | $this->config['project_key'], 33 | $this->config['issue_type'], 34 | $title 35 | ); 36 | 37 | $response = $this->client->get('search', [ 38 | 'query' => ['jql' => $jql] 39 | ]); 40 | 41 | $result = json_decode($response->getBody(), true); 42 | $issueId = !empty($result['issues']) ? $result['issues'][0]['id'] : null; 43 | 44 | $documentData = [ 45 | 'fields' => [ 46 | 'project' => ['key' => $this->config['project_key']], 47 | 'summary' => $title, 48 | 'description' => [ 49 | 'version' => 1, 50 | 'type' => 'doc', 51 | 'content' => [ 52 | [ 53 | 'type' => 'paragraph', 54 | 'content' => [ 55 | [ 56 | 'type' => 'text', 57 | 'text' => $content 58 | ] 59 | ] 60 | ] 61 | ] 62 | ], 63 | 'issuetype' => ['name' => $this->config['issue_type']], 64 | ], 65 | ]; 66 | 67 | if ($issueId) { 68 | // Update existing issue 69 | $this->client->put("issue/{$issueId}", [ 70 | 'json' => $documentData 71 | ]); 72 | } else { 73 | // Create new issue 74 | $this->client->post('issue', [ 75 | 'json' => $documentData 76 | ]); 77 | } 78 | 79 | return true; 80 | } catch (Exception $e) { 81 | // Log error or handle it as needed 82 | return false; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /examples/RouteServiceProvider.md: -------------------------------------------------------------------------------- 1 | # Documentation: RouteServiceProvider.php 2 | 3 | Original file: `app/Providers/RouteServiceProvider.php` 4 | 5 | ### RouteServiceProvider Documentation 6 | 7 | ## Introduction 8 | 9 | This document provides comprehensive technical documentation for the `RouteServiceProvider` class, which is a core component of the application's route management system. This class is responsible for defining and managing the application's routes, ensuring that users are correctly redirected to the appropriate pages based on their request. It’s a critical part of the application’s architecture, facilitating user experience and application functionality. 10 | 11 | ### Table of Contents 12 | 13 | 1. **Purpose and Role** 14 | 2. **Method Details** 15 | * 2.1 `boot()` Method 16 | * 2.2 `routes()` Method 17 | 3. **Code Breakdown** 18 | * 3.1 Route Configuration 19 | * 3.2 Route Handling 20 | 4. **Dependencies** 21 | 5. **Notes & Considerations** 22 | 23 | ### 2.1 `boot()` Method 24 | 25 | The `boot()` method is the entry point for the `RouteServiceProvider` class. It's called automatically when the service is initialized. Its primary responsibility is to configure the application's route system. 26 | 27 | **Purpose:** The `boot()` method initializes the route configuration, ensuring that the application's routes are correctly set up and ready for use. 28 | 29 | **Parameters:** The `boot()` method takes no parameters. 30 | 31 | **Return Value:** The `boot()` method does not return a value. 32 | 33 | **Functionality:** 34 | 35 | * The `boot()` method calls the `routes()` method, which is responsible for defining and applying the route configuration. 36 | * The `routes()` method uses the `Route::middleware()` and `Route::namespace()` methods to configure the route. 37 | * The `routes()` method uses the `Route::group()` method to define the route's namespace. 38 | 39 | **Detailed Explanation:** 40 | 41 | The `routes()` method is the core of the route configuration process. It utilizes the `Route::middleware()` and `Route::namespace()` methods to establish the route's configuration. The `Route::middleware()` method sets the middleware that will be applied to the route. The `Route::namespace()` method sets the namespace for the route. The `routes()` method then uses these methods to define the route's URL, middleware, and namespace. 42 | 43 | **Example:** 44 | 45 | The `routes()` method will configure the route to: 46 | 47 | * Apply the `web` middleware. 48 | * Place the route within the `App\Http\Controllers` namespace. 49 | * Group the route under the `live` namespace. 50 | 51 | ### 2.2 `routes()` Method 52 | 53 | The `routes()` method is the primary method for defining and applying route configurations. It's called automatically by the `boot()` method. 54 | 55 | **Purpose:** This method defines the route configuration for the application. 56 | 57 | **Parameters:** 58 | 59 | * `function () { ... }`: A closure that defines the route configuration. This closure is executed when the `routes()` method is called. 60 | * `Route::middleware()`: This method sets the middleware that will be applied to the route. 61 | * `Route::namespace()`: This method sets the namespace for the route. 62 | * `Route::group()`: This method defines the route's namespace. 63 | 64 | **Functionality:** 65 | 66 | The `routes()` method takes the parameters defined in the previous section and uses them to configure the route. It then calls the `Route::middleware()` and `Route::namespace()` methods to apply the configuration to the route. 67 | 68 | **Example:** 69 | 70 | The `routes()` method might contain the following configuration: 71 | 72 | ```php 73 | Route::middleware('web')->namespace(self::NAMESPACE)->group(base_path('routes/public.php')); 74 | Route::middleware('web')->namespace(self::NAMESPACE)->group(base_path('routes/swan.php')); 75 | Route::middleware('web')->namespace(self::NAMESPACE)->group(base_path('routes/live.php')); 76 | ``` 77 | 78 | This configuration defines the route to: 79 | 80 | * Apply the `web` middleware. 81 | * Place the route within the `App\Http\Controllers` namespace. 82 | * Group the route under the `live` namespace. 83 | 84 | ### 3.1 Route Configuration 85 | 86 | The `Route` class is responsible for defining the routes that the application will handle. 87 | 88 | **Purpose:** The `Route` class defines the routes that the application will handle. 89 | 90 | **Parameters:** 91 | 92 | * `$route`: The `Route` object, which represents the route configuration. 93 | * `$namespace`: The namespace of the route. 94 | * `$middleware`: The middleware to apply to the route. 95 | * `$group`: The namespace of the route. 96 | 97 | **Functionality:** 98 | 99 | The `Route` class uses the `Route::middleware()` and `Route::namespace()` methods to configure the route. It also uses the `Route::group()` method to define the route's namespace. 100 | 101 | **Example:** 102 | 103 | The `Route` class might contain the following configuration: 104 | 105 | ```php 106 | class Route { 107 | public function __construct(Route $route) { 108 | $this->route = $route; 109 | } 110 | } 111 | ``` 112 | 113 | This class defines a `Route` object that represents the route configuration. 114 | 115 | ### 3.2 Route Handling 116 | 117 | The `Route` class handles the logic for processing routes. 118 | 119 | **Purpose:** The `Route` class handles the logic for processing routes. 120 | 121 | **Functionality:** 122 | 123 | The `Route` class uses the `Route::middleware()` and `Route::namespace()` methods to configure the route. It also uses the `Route::group()` method to define the route's namespace. 124 | 125 | **Example:** 126 | 127 | The `Route` class might contain the following logic: 128 | 129 | ```php 130 | public function handle($route) { 131 | // Process the route here 132 | echo "Route processed: " . $route->url; 133 | } 134 | ``` 135 | 136 | This method will be called when the route is processed. 137 | 138 | ### 4. Dependencies 139 | 140 | The `RouteServiceProvider` class has no dependencies. It is a self-contained class that doesn't rely on any external libraries or frameworks. 141 | 142 | ### 5. Notes & Considerations 143 | 144 | * **Route Naming Conventions:** It's recommended to use consistent naming conventions for routes to improve readability and maintainability. 145 | * **Route Parameters:** The `Route::group()` method allows you to define route parameters. These parameters are passed to the route handler. 146 | * **Route Security:** Consider implementing route security measures to prevent unauthorized access to routes. 147 | * **Route Optimization:** Explore techniques for route optimization to improve performance. 148 | 149 | This documentation provides a detailed overview of the `RouteServiceProvider` class. It is intended to be a reference for developers who need to understand and maintain this critical component of the application's routing system. 150 | -------------------------------------------------------------------------------- /config/docudoodle.php: -------------------------------------------------------------------------------- 1 | env('OPENAI_API_KEY', ''), 14 | 15 | /* 16 | |-------------------------------------------------------------------------- 17 | | Claude API Key 18 | |-------------------------------------------------------------------------- 19 | | 20 | | This key is used to authenticate with the Claude API. 21 | | 22 | */ 23 | 'claude_api_key' => env('CLAUDE_API_KEY', ''), 24 | 25 | /* 26 | |-------------------------------------------------------------------------- 27 | | Default Model 28 | |-------------------------------------------------------------------------- 29 | | 30 | | The default model to use for generating documentation. 31 | | 32 | */ 33 | 'default_model' => env('DOCUDOODLE_MODEL', 'gpt-4o-mini'), 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Maximum Tokens 38 | |-------------------------------------------------------------------------- 39 | | 40 | | The maximum number of tokens to use for API calls. 41 | | 42 | */ 43 | 'max_tokens' => env('DOCUDOODLE_MAX_TOKENS', 10000), 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Default Extensions 48 | |-------------------------------------------------------------------------- 49 | | 50 | | The default file extensions to process. 51 | | 52 | */ 53 | 'default_extensions' => ['php', 'yaml', 'yml'], 54 | 55 | /* 56 | |-------------------------------------------------------------------------- 57 | | Default Skip Directories 58 | |-------------------------------------------------------------------------- 59 | | 60 | | The default directories to skip during processing. 61 | | 62 | */ 63 | 'default_skip_dirs' => ['vendor/', 'node_modules/', 'tests/', 'cache/'], 64 | 65 | /* 66 | |-------------------------------------------------------------------------- 67 | | Ollama Settings 68 | |-------------------------------------------------------------------------- 69 | | 70 | | Settings for the Ollama API which runs locally. 71 | | 72 | | host: The host where Ollama is running (default: localhost) 73 | | port: The port Ollama is listening on (default: 11434) 74 | | 75 | */ 76 | 'ollama_host' => env('OLLAMA_HOST', 'localhost'), 77 | 'ollama_port' => env('OLLAMA_PORT', '11434'), 78 | 79 | /* 80 | |-------------------------------------------------------------------------- 81 | | Gemini API Key 82 | |-------------------------------------------------------------------------- 83 | | 84 | | This key is used to authenticate with the Gemini API. 85 | | 86 | */ 87 | 'gemini_api_key' => env('GEMINI_API_KEY', ''), 88 | 89 | /* 90 | |-------------------------------------------------------------------------- 91 | | Azure OpenAI Settings 92 | |-------------------------------------------------------------------------- 93 | | 94 | | Settings for Azure OpenAI integration. 95 | | 96 | | endpoint: Your Azure OpenAI resource endpoint 97 | | api_key: Your Azure OpenAI API key 98 | | deployment: Your Azure OpenAI deployment ID 99 | | api_version: Azure OpenAI API version (default: 2023-05-15) 100 | | 101 | */ 102 | 'azure_endpoint' => env('AZURE_OPENAI_ENDPOINT', ''), 103 | 'azure_api_key' => env('AZURE_OPENAI_API_KEY', ''), 104 | 'azure_deployment' => env('AZURE_OPENAI_DEPLOYMENT', ''), 105 | 'azure_api_version' => env('AZURE_OPENAI_API_VERSION', '2023-05-15'), 106 | 107 | /* 108 | |-------------------------------------------------------------------------- 109 | | Default API Provider 110 | |-------------------------------------------------------------------------- 111 | | 112 | | The default API provider to use for generating documentation. 113 | | Supported values: 'openai', 'ollama', 'claude', 'gemini', 'azure' 114 | | 115 | */ 116 | 'default_api_provider' => env('DOCUDOODLE_API_PROVIDER', 'openai'), 117 | 118 | /* 119 | |-------------------------------------------------------------------------- 120 | | Jira Settings 121 | |-------------------------------------------------------------------------- 122 | | 123 | | Settings for Jira integration. 124 | | 125 | | enabled: Enable/disable Jira integration 126 | | host: Your Jira instance URL (e.g., https://your-domain.atlassian.net) 127 | | api_token: Your Jira API token 128 | | email: Your Atlassian account email 129 | | project_key: The Jira project key where documentation should be created 130 | | issue_type: The type of issue to create (default: 'Documentation') 131 | | 132 | */ 133 | 'jira' => [ 134 | 'enabled' => env('DOCUDOODLE_JIRA_ENABLED', false), 135 | 'host' => env('JIRA_HOST', ''), 136 | 'api_token' => env('JIRA_API_TOKEN', ''), 137 | 'email' => env('JIRA_EMAIL', ''), 138 | 'project_key' => env('JIRA_PROJECT_KEY', ''), 139 | 'issue_type' => env('JIRA_ISSUE_TYPE', 'Documentation'), 140 | ], 141 | 142 | /* 143 | |-------------------------------------------------------------------------- 144 | | Confluence Settings 145 | |-------------------------------------------------------------------------- 146 | | 147 | | Settings for Confluence integration. 148 | | 149 | | enabled: Enable/disable Confluence integration 150 | | host: Your Confluence instance URL (e.g., https://your-domain.atlassian.net) 151 | | api_token: Your Confluence API token 152 | | email: Your Atlassian account email 153 | | space_key: The Confluence space where documentation should be created 154 | | parent_page_id: Optional parent page ID under which to create documentation 155 | | 156 | */ 157 | 'confluence' => [ 158 | 'enabled' => env('DOCUDOODLE_CONFLUENCE_ENABLED', false), 159 | 'host' => env('CONFLUENCE_HOST', ''), 160 | 'api_token' => env('CONFLUENCE_API_TOKEN', ''), 161 | 'email' => env('CONFLUENCE_EMAIL', ''), 162 | 'space_key' => env('CONFLUENCE_SPACE_KEY', ''), 163 | 'parent_page_id' => env('CONFLUENCE_PARENT_PAGE_ID', ''), 164 | ], 165 | 166 | /* 167 | |-------------------------------------------------------------------------- 168 | | Cache Settings 169 | |-------------------------------------------------------------------------- 170 | | 171 | | Configure the caching mechanism to skip unchanged files. 172 | | 173 | | use_cache: Enable or disable caching (default: true). 174 | | cache_file_path: Absolute path to the cache file. If null or empty, 175 | | it defaults to '.docudoodle_cache.json' inside the output directory. 176 | | bypass_cache: Force regeneration of all documents even if they haven't changed. 177 | | This will still update the cache file with new hashes (default: false). 178 | | 179 | */ 180 | 'use_cache' => env('DOCUDOODLE_USE_CACHE', true), 181 | 'cache_file_path' => env('DOCUDOODLE_CACHE_PATH', null), 182 | 'bypass_cache' => env('DOCUDOODLE_BYPASS_CACHE', false), 183 | 184 | /* 185 | |-------------------------------------------------------------------------- 186 | | Prompt Template 187 | |-------------------------------------------------------------------------- 188 | | 189 | | The path to the prompt template file. 190 | | 191 | */ 192 | 'prompt_template' => env('DOCUDOODLE_PROMPT_TEMPLATE', __DIR__.'/../../resources/templates/default-prompt.md'), 193 | ]; -------------------------------------------------------------------------------- /examples/BroadcastServiceProvider.md: -------------------------------------------------------------------------------- 1 | # Documentation: BroadcastServiceProvider.php 2 | 3 | Original file: `app/Providers/BroadcastServiceProvider.php` 4 | 5 | ## BroadcastServiceProvider Documentation 6 | 7 | ## Title: BroadcastServiceProvider - Core Provider for Broadcast Management 8 | 9 | **Introduction** 10 | 11 | The `BroadcastServiceProvider` class is a core component of the `Broadcast` system, responsible for managing and routing broadcast events across the application. Broadcasts are crucial for real-time notifications and updates, enabling applications to react to user actions and events. This service handles the logic for receiving, processing, and delivering broadcast events to various subscribers. This documentation provides a detailed overview of the class's functionality, methods, and key considerations. 12 | 13 | --- 14 | 15 | ### Table of Contents 16 | 17 | 1. **Purpose and Role** 18 | 2. **Method Details** 19 | * 2.1. `boot()` 20 | * 2.2. `broadcast()` 21 | * 2.3. `handleBroadcast()` 22 | * 2.4. (Optional) `route()` - (If applicable, document any route handling) 23 | 3. **Dependencies** 24 | 4. **Configuration (if applicable)** 25 | 5. **Example Usage** 26 | 27 | --- 28 | 29 | ### 2.1. `boot()` 30 | 31 | The `boot()` method is the entry point for the service. It's triggered when the service is initialized. It performs the following key actions: 32 | 33 | * **Purpose:** The `boot()` method initializes the broadcast system by routing all registered routes. 34 | * **Parameters:** It takes no parameters. 35 | * **Return Value:** It returns `void`. 36 | 37 | ```php 38 | getTimestamp()); 87 | 88 | // Example: Perform some action based on the event 89 | // For example, update a database record. 90 | // $this->updateDatabaseRecord($event->getEventType()); 91 | 92 | // Return a success message or a status code. 93 | return 'Broadcast received successfully.'; 94 | } 95 | } 96 | ``` 97 | 98 | **Explanation:** 99 | 100 | The `broadcast()` method is the primary method for receiving broadcast events. 101 | 102 | 1. **`Broadcast $event`:** The method takes a `Broadcast` object as input. This object contains the event data, which is crucial for processing the event. 103 | 2. **`error_log('Broadcast Event Received: ' . $event->getTimestamp());`:** This line logs the timestamp of the event to the system's error log. This is useful for debugging and auditing. 104 | 3. **`// Perform some action based on the event`:** This is a placeholder for the actual logic that should be executed when the event is received. The example shows how to log the event and potentially update a database record. The specific actions will depend on the requirements of the application. 105 | 4. **`return 'Broadcast received successfully.';`:** This line returns a success message to indicate that the event has been successfully received. 106 | 107 | --- 108 | 109 | ### 2.3. `handleBroadcast()` 110 | 111 | The `handleBroadcast()` method is responsible for routing the received broadcast events to the appropriate subscribers. It's a crucial component for ensuring that events are delivered to the correct destinations. 112 | 113 | * **Purpose:** This method receives broadcast events and routes them to the correct subscribers. 114 | * **Parameters:** It takes no parameters. 115 | * **Return Value:** It returns `void`. 116 | 117 | ```php 118 | sendSubscriberNotification($event->getEventType()); 135 | 136 | // Return a success message or a status code. 137 | return 'Broadcast received and routed to subscribers.'; 138 | } 139 | } 140 | ``` 141 | 142 | **Explanation:** 143 | 144 | The `handleBroadcast()` method is responsible for routing the received broadcast events to the correct subscribers. 145 | 146 | 1. **`Broadcast $event`:** The method takes a `Broadcast` object as input. 147 | 2. **`// Route the event to the appropriate subscribers`:** This is a placeholder for the actual logic that should be executed when the event is received. The example shows how to send the event to a subscriber named 'user-notifications'. In a real application, this would involve sending the event to the correct subscribers based on the event type. 148 | 3. **`return 'Broadcast received and routed to subscribers.';`:** This line returns a success message to indicate that the event has been successfully received and routed to subscribers. 149 | 150 | --- 151 | 152 | ### 2.4. (Optional) `route()` 153 | 154 | The `route()` method is included for documentation purposes. If the `BroadcastServiceProvider` has defined routes for specific broadcast events, this method would be used to handle those routes. This would typically involve defining a route in the `routes/channels.php` file. 155 | 156 | * **Purpose:** Handles specific broadcast routes. 157 | * **Parameters:** None. 158 | * **Return Value:** None. 159 | 160 | --- 161 | 162 | ### 3. Dependencies 163 | 164 | * **Broadcast:** The `Broadcast` namespace is used for the core broadcast functionality. 165 | * **Illuminate\Support\Facades\Broadcast:** This provides the `Broadcast` facade, which simplifies the creation and management of broadcast routes. 166 | 167 | --- 168 | 169 | ### 4. Configuration (if applicable) 170 | 171 | This section would be included if the `BroadcastServiceProvider` had configuration options that could be adjusted. For example, it might include settings for: 172 | 173 | * **Subscriber Notifications:** The list of subscribers to which events are sent. 174 | * **Event Types:** The types of events that are processed. 175 | * **Event Priority:** The priority of different events. 176 | 177 | --- 178 | 179 | ### 5. Example Usage 180 | 181 | ```php 182 | getEventType()); 186 | 187 | $broadcastServiceProvider = new BroadcastServiceProvider(); 188 | $broadcastServiceProvider->handleBroadcast($event); 189 | 190 | // You can now use the event data to perform your desired actions. 191 | ``` 192 | 193 | This example demonstrates how to create a `Broadcast` object and how to use the `handleBroadcast()` method to process the event. 194 | 195 | **Note:** This is a basic example and would need to be expanded to include more detailed logic and error handling. The specific implementation of the `handleBroadcast()` method will depend on the requirements of the application. 196 | -------------------------------------------------------------------------------- /src/Commands/GenerateDocsCommand.php: -------------------------------------------------------------------------------- 1 | option('api-provider') ?: config('docudoodle.default_api_provider', 'openai'); 48 | $apiKey = ''; 49 | 50 | if ($apiProvider === 'openai') { 51 | $apiKey = config('docudoodle.openai_api_key'); 52 | if (empty($apiKey)) { 53 | $this->error('Oops! OpenAI API key is not set in the configuration!'); 54 | return 1; 55 | } 56 | } elseif ($apiProvider === 'claude') { 57 | $apiKey = config('docudoodle.claude_api_key'); 58 | if (empty($apiKey)) { 59 | $this->error('Oops! Claude API key is not set in the configuration!'); 60 | return 1; 61 | } 62 | } elseif ($apiProvider === 'gemini') { 63 | $apiKey = config('docudoodle.gemini_api_key'); 64 | if (empty($apiKey)) { 65 | $this->error('Oops! Gemini API key is not set in the configuration!'); 66 | return 1; 67 | } 68 | } elseif ($apiProvider === 'azure') { 69 | $apiKey = config('docudoodle.azure_api_key'); 70 | if (empty($apiKey)) { 71 | $this->error('Oops! Azure OpenAI API key is not set in the configuration!'); 72 | return 1; 73 | } 74 | } 75 | 76 | // Parse command options with config fallbacks 77 | $sourceDirs = $this->option('source'); 78 | if (empty($sourceDirs)) { 79 | $sourceDirs = config('docudoodle.source_dirs', ['app/', 'config/', 'routes/', 'database/']); 80 | } 81 | 82 | $outputDir = $this->option('output'); 83 | if (empty($outputDir)) { 84 | $outputDir = config('docudoodle.output_dir', 'documentation'); 85 | } 86 | 87 | $model = $this->option('model'); 88 | if (empty($model)) { 89 | $model = config('docudoodle.default_model', 'gpt-4o-mini'); 90 | } 91 | 92 | $maxTokens = $this->option('max-tokens'); 93 | if (empty($maxTokens)) { 94 | $maxTokens = (int) config('docudoodle.max_tokens', 10000); 95 | } else { 96 | $maxTokens = (int) $maxTokens; 97 | } 98 | 99 | $extensions = $this->option('extensions'); 100 | if (empty($extensions)) { 101 | $extensions = config('docudoodle.extensions', ['php', 'yaml', 'yml']); 102 | } 103 | 104 | $skipSubdirs = $this->option('skip'); 105 | if (empty($skipSubdirs)) { 106 | $skipSubdirs = config('docudoodle.default_skip_dirs', ['vendor/', 'node_modules/', 'tests/', 'cache/']); 107 | } 108 | 109 | $ollamaHost = config('docudoodle.ollama_host', 'localhost'); 110 | $ollamaPort = config('docudoodle.ollama_port', 5000); 111 | 112 | 113 | // Azure OpenAI specific configuration 114 | $azureEndpoint = $this->option('azure-endpoint'); 115 | $azureDeployment = $this->option('azure-deployment'); 116 | $azureApiVersion = $this->option('azure-api-version'); 117 | 118 | if (empty($azureEndpoint)) { 119 | $azureEndpoint = config('docudoodle.azure_endpoint', ''); 120 | } 121 | if (empty($azureDeployment)) { 122 | $azureDeployment = config('docudoodle.azure_deployment', ''); 123 | } 124 | if (empty($azureApiVersion)) { 125 | $azureApiVersion = config('docudoodle.azure_api_version', '2023-05-15'); 126 | } 127 | 128 | 129 | // Handle Jira configuration 130 | $jiraConfig = []; 131 | if ($this->option('jira')) { 132 | $jiraConfig = [ 133 | 'enabled' => true, 134 | 'host' => config('docudoodle.jira.host'), 135 | 'api_token' => config('docudoodle.jira.api_token'), 136 | 'email' => config('docudoodle.jira.email'), 137 | 'project_key' => config('docudoodle.jira.project_key'), 138 | 'issue_type' => config('docudoodle.jira.issue_type'), 139 | ]; 140 | 141 | // Validate Jira configuration 142 | if (empty($jiraConfig['host']) || empty($jiraConfig['api_token']) || 143 | empty($jiraConfig['email']) || empty($jiraConfig['project_key'])) { 144 | $this->error('Jira integration is enabled but configuration is incomplete. Please check your .env file.'); 145 | return 1; 146 | } 147 | } 148 | 149 | // Handle Confluence configuration 150 | $confluenceConfig = []; 151 | if ($this->option('confluence')) { 152 | $confluenceConfig = [ 153 | 'enabled' => true, 154 | 'host' => config('docudoodle.confluence.host'), 155 | 'api_token' => config('docudoodle.confluence.api_token'), 156 | 'email' => config('docudoodle.confluence.email'), 157 | 'space_key' => config('docudoodle.confluence.space_key'), 158 | 'parent_page_id' => config('docudoodle.confluence.parent_page_id'), 159 | ]; 160 | 161 | // Validate Confluence configuration 162 | if (empty($confluenceConfig['host']) || empty($confluenceConfig['api_token']) || 163 | empty($confluenceConfig['email']) || empty($confluenceConfig['space_key'])) { 164 | $this->error('Confluence integration is enabled but configuration is incomplete. Please check your .env file.'); 165 | return 1; 166 | } 167 | } 168 | 169 | // Set output directory to 'none' if --no-files is specified 170 | if ($this->option('no-files')) { 171 | $outputDir = 'none'; 172 | } 173 | 174 | // Convert relative paths to absolute paths based on Laravel's base path 175 | $sourceDirs = array_map(function($dir) { 176 | return base_path($dir); 177 | }, $sourceDirs); 178 | 179 | $outputDir = base_path($outputDir); 180 | 181 | $this->info('Starting documentation generation...'); 182 | $this->info('Source directories: ' . implode(', ', $sourceDirs)); 183 | $this->info('Output directory: ' . $outputDir); 184 | $this->info('API provider: ' . $apiProvider); 185 | 186 | // Determine cache settings 187 | $useCache = !$this->option('no-cache') && config('docudoodle.use_cache', true); 188 | $bypassCache = $this->option('bypass-cache'); 189 | 190 | $cachePath = $this->option('cache-path'); 191 | if (empty($cachePath)) { 192 | $cachePath = config('docudoodle.cache_file_path', null); 193 | } 194 | if ($useCache) { 195 | $this->info('Cache enabled.' . ($cachePath ? " Path: {$cachePath}" : ' Using default path')); 196 | if ($bypassCache) { 197 | $this->info('Bypass cache flag set: Documents will be regenerated but cache will be updated.'); 198 | } 199 | } else { 200 | $this->info('Cache disabled.' . ($this->option('no-cache') ? ' (--no-cache option)' : ' (from config)')); 201 | } 202 | 203 | $promptTemplate = $this->option('prompt-template'); 204 | if (empty($promptTemplate)) { 205 | $promptTemplate = config('docudoodle.prompt_template', __DIR__.'/../../resources/templates/default-prompt.md'); 206 | } 207 | 208 | try { 209 | $generator = new Docudoodle( 210 | openaiApiKey: $apiKey, 211 | sourceDirs: $sourceDirs, 212 | outputDir: $outputDir, 213 | model: $model, 214 | maxTokens: $maxTokens, 215 | allowedExtensions: $extensions, 216 | skipSubdirectories: $skipSubdirs, 217 | apiProvider: $apiProvider, 218 | ollamaHost: $ollamaHost, 219 | ollamaPort: $ollamaPort, 220 | promptTemplate: $promptTemplate, 221 | useCache: $useCache, 222 | cacheFilePath: $cachePath, 223 | forceRebuild: $bypassCache, 224 | azureEndpoint: $azureEndpoint, 225 | azureDeployment: $azureDeployment, 226 | azureApiVersion: $azureApiVersion, 227 | jiraConfig: $jiraConfig, 228 | confluenceConfig: $confluenceConfig 229 | ); 230 | 231 | $generator->generate(); 232 | 233 | $this->info('Documentation generated successfully!'); 234 | return 0; 235 | } catch (\Exception $e) { 236 | $this->error('Error generating documentation: ' . $e->getMessage()); 237 | return 1; 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | Docudoodle v2.2.0 6 |

7 | 8 | This is Docudoodle! 👋 The PHP documentation generator that analyzes your codebase and creates comprehensive documentation using AI. It's perfect for helping you and your team understand your code better through detailed insights into your application's structure and functionality. 9 | 10 | Docudoodle is fab if you've taken on an application with no existing documentation allowing your team to get up to speed right away. 11 | 12 | Docudoodle writes Markdown files which show up great in Github and other source control providers allowing you to get teams up to speed in a matter of moments. 13 | 14 | Better yet, Docudoodle skips already existing documentation files. Allowing a quick top-up run after you have concluded a feature, meaning that the entire process of getting good documentation written is a thing of the past 🚀 15 | 16 | ## Table of Contents 17 | 18 | - [Examples](#examples) 19 | - [Features](#features) 20 | - [Installation](#installation) 21 | - [Usage](#usage) 22 | - [Configuration](#configuration) 23 | - [OpenAI API Key](#openai-api-key) 24 | - [Claude API Key](#claude-api-key) 25 | - [Gemini API Key](#gemini-api-key) 26 | - [Azure OpenAI Settings](#azure-openai-settings) 27 | - [Model Selection](#model-selection) 28 | - [API Provider](#api-provider) 29 | - [Ollama Configuration](#ollama-configuration) 30 | - [Token Limits](#token-limits) 31 | - [File Extensions](#file-extensions) 32 | - [Skip Directories](#skip-directories) 33 | - [Caching](#caching) 34 | - [Template Variables](#template-variables) 35 | - [Custom Template Example](#custom-template-example) 36 | - [Using Azure OpenAI](#using-azure-openai) 37 | - [Documentation Output Options](#documentation-output-options) 38 | - [Jira Integration](#jira-integration) 39 | - [Confluence Integration](#confluence-integration) 40 | - [Dependencies](#dependencies) 41 | - [Command Options](#command-options) 42 | - [Running Tests](#running-tests) 43 | - [License](#license) 44 | - [Contributing](#contributing) 45 | 46 | ## Examples 47 | 48 | If you want to see what the output of some documentation looks like, check out the [examples folder](https://github.com/genericmilk/docudoodle/tree/main/examples) in this repo which contains a few examples 🥰 49 | 50 | ## Features 51 | 52 | - **Automatic Documentation Generation**: Effortlessly generates documentation for PHP files by analyzing their content. 53 | - **Smart Caching**: Only processes files that have changed since the last run, saving time and API costs. 54 | - **Orphan Cleanup**: Automatically removes documentation for deleted source files to keep your docs in sync. 55 | - **Flexible AI Integration**: Choose between OpenAI's powerful cloud API, Claude API, Google's Gemini API, or run locally with Ollama models for complete privacy. 56 | - **Ollama Support**: Generate documentation completely offline using your own local Ollama models - perfect for private codebases or when you need to work without an internet connection. 57 | - **Azure OpenAI Support**: Use Microsoft's Azure OpenAI service for documentation generation with enterprise-grade security and compliance. 58 | - **Customizable**: Easily configure source directories, output folders, and other settings to match your workflow. 59 | - **Command-Line Interface**: Includes a simple command-line script for quick documentation generation. 60 | 61 | ## Installation 62 | 63 | Getting started with Docudoodle is super easy! Just use Composer with this command: 64 | 65 | ``` 66 | composer require genericmilk/docudoodle 67 | ``` 68 | 69 | This will set up all the necessary dependencies defined in the `composer.json` file. 70 | 71 | ## Usage 72 | 73 | Ready to create some amazing documentation? Just run this simple command: 74 | 75 | ``` 76 | php artisan docudoodle:generate 77 | ``` 78 | 79 | If you're using OpenAI, make sure to set your API key which looks a bit like this: `sk-XXXXXXXX` in the application configuration file. If you're using Ollama, ensure it's running on your system and properly configured in your settings. For Claude, set your API key in the configuration file. 80 | 81 | ## Configuration 82 | 83 | Docudoodle is highly customizable! The package includes a configuration file at `config/docudoodle.php` that lets you tailor everything to your needs: 84 | 85 | ### OpenAI API Key 86 | 87 | ```php 88 | 'openai_api_key' => env('OPENAI_API_KEY', ''), 89 | ``` 90 | 91 | Set your OpenAI API key here or in your `.env` file as `OPENAI_API_KEY`. Keys typically start with `sk-XXXXXXXX`. 92 | 93 | ### Claude API Key 94 | 95 | ```php 96 | 'claude_api_key' => env('CLAUDE_API_KEY', ''), 97 | ``` 98 | 99 | Set your Claude API key here or in your `.env` file as `CLAUDE_API_KEY`. 100 | 101 | ### Gemini API Key 102 | 103 | ```php 104 | 'gemini_api_key' => env('GEMINI_API_KEY', ''), 105 | ``` 106 | 107 | Set your Gemini API key here or in your `.env` file as `GEMINI_API_KEY`. 108 | 109 | ### Azure OpenAI Settings 110 | 111 | ```php 112 | 'azure_endpoint' => env('AZURE_OPENAI_ENDPOINT', ''), 113 | 'azure_api_key' => env('AZURE_OPENAI_API_KEY', ''), 114 | 'azure_deployment' => env('AZURE_OPENAI_DEPLOYMENT', ''), 115 | 'azure_api_version' => env('AZURE_OPENAI_API_VERSION', '2023-05-15'), 116 | ``` 117 | 118 | Configure Azure OpenAI integration. You need to provide the endpoint URL, API key, deployment ID, and optionally the API version if using Azure as your API provider. 119 | 120 | ### Model Selection 121 | 122 | ```php 123 | 'default_model' => env('DOCUDOODLE_MODEL', 'gpt-4o-mini'), 124 | ``` 125 | 126 | Choose which model to use. The default is `gpt-4o-mini` for OpenAI, but you can specify any OpenAI model, Claude model, Gemini model, or Ollama model name in your `.env` file with the `DOCUDOODLE_MODEL` variable. 127 | 128 | ### API Provider 129 | 130 | ```php 131 | 'default_api_provider' => env('DOCUDOODLE_API_PROVIDER', 'openai'), 132 | ``` 133 | 134 | Choose which API provider to use: 'openai' for cloud-based generation, 'azure' for Azure OpenAI, 'claude' for Claude API, 'gemini' for Gemini API, or 'ollama' for local generation. Set in your `.env` file with `DOCUDOODLE_API_PROVIDER`. 135 | 136 | ### Ollama Configuration 137 | 138 | ```php 139 | 'ollama_host' => env('OLLAMA_HOST', 'localhost'), 140 | 'ollama_port' => env('OLLAMA_PORT', '11434'), 141 | ``` 142 | 143 | Configure your Ollama host and port if using Ollama as the API provider. The defaults work with standard Ollama installations. 144 | 145 | ### Token Limits 146 | 147 | ```php 148 | 'max_tokens' => env('DOCUDOODLE_MAX_TOKENS', 10000), 149 | ``` 150 | 151 | Control the maximum number of tokens for API calls. Adjust this in your `.env` file with `DOCUDOODLE_MAX_TOKENS` if needed. 152 | 153 | ### File Extensions 154 | 155 | ```php 156 | 'default_extensions' => ['php', 'yaml', 'yml'], 157 | ``` 158 | 159 | Specify which file types Docudoodle should process. By default, it handles PHP and YAML files. 160 | 161 | ### Skip Directories 162 | 163 | ```php 164 | 'default_skip_dirs' => ['vendor/', 'node_modules/', 'tests/', 'cache/', '/wildcard/*/path/'], 165 | ``` 166 | 167 | Define directories that should be excluded from documentation generation. 168 | 169 | You can publish the configuration file to your project using: 170 | 171 | ``` 172 | php artisan vendor:publish --tag=docudoodle-config 173 | ``` 174 | 175 | ## Caching 176 | 177 | To improve performance and reduce API calls on subsequent runs, Docudoodle implements a caching mechanism. 178 | 179 | **How it works:** 180 | 181 | 1. When a source file is processed, a hash of its content is calculated. 182 | 2. This hash is stored in a cache file (`.docudoodle_cache.json` by default) alongside a hash representing the relevant parts of the configuration (model, prompt template, API provider). 183 | 3. On the next run: 184 | - The overall configuration hash is checked. If it differs, the cache is invalidated, and all files are reprocessed. 185 | - If the configuration hash matches, the content hash of each source file is compared to the stored hash. 186 | - Files with matching hashes are skipped. 187 | - Files with different hashes or files not found in the cache are processed, and the cache is updated. 188 | 4. **Orphan Cleanup:** If a source file is deleted, Docudoodle detects this and removes its corresponding documentation file from the output directory and its entry from the cache. 189 | 190 | **Configuration:** 191 | 192 | You can control caching via `config/docudoodle.php`: 193 | 194 | - `use_cache` (boolean, default: `true`): Set to `false` to disable the caching mechanism entirely. 195 | - `cache_file_path` (string|null, default: `null`): Specifies the absolute path to the cache file. If `null` or empty, it defaults to `.docudoodle_cache.json` inside the configured `output_dir`. 196 | - `force_rebuild` (boolean, default: `false`): When set to `true`, regenerates all documentation regardless of the cache status. Useful for manual cache invalidation. 197 | 198 | **Command Line Options:** 199 | 200 | - `--no-cache`: Disables caching for this run, forcing reprocessing of all files. This overrides the `use_cache` config setting. 201 | - `--force-rebuild`: Forces regeneration of all documentation files regardless of cache status. This overrides the `force_rebuild` config setting. 202 | - `--cache-path="/path/to/your/cache.json"`: Specifies a custom absolute path for the cache file for this run, overriding the `cache_file_path` config setting. 203 | 204 | **Cache File Format:** 205 | 206 | The cache file is a JSON document with the following structure: 207 | 208 | ```json 209 | { 210 | "_config_hash": "abc123...", // Hash of current configuration settings 211 | "/path/to/file1.php": "def456...", // File path and content hash pairs 212 | "/path/to/file2.php": "789ghi..." 213 | } 214 | ``` 215 | 216 | When the configuration changes (different model, API provider, or prompt template), the `_config_hash` value changes, which triggers a full rebuild on the next run. 217 | 218 | ## Template Variables 219 | 220 | When creating custom prompt templates for documentation generation, you can use the following variables: 221 | 222 | | Variable | Description | 223 | | ---------------- | ---------------------------------------------- | 224 | | `{FILE_PATH}` | The full path to the file being documented | 225 | | `{FILE_CONTENT}` | The content of the file being processed | 226 | | `{FILE_NAME}` | The filename with extension (e.g., `User.php`) | 227 | | `{EXTENSION}` | The file extension (e.g., `php`) | 228 | | `{BASE_NAME}` | The filename without extension (e.g., `User`) | 229 | | `{DIRECTORY}` | The directory containing the file | 230 | 231 | ## Custom Template Example 232 | 233 | Create a markdown file for your custom prompt template: 234 | 235 | ````markdown 236 | # My Custom Documentation Template 237 | 238 | Please document this {EXTENSION} file: {FILE_PATH} 239 | 240 | Here's the code: 241 | 242 | ```{EXTENSION} 243 | {FILE_CONTENT} 244 | ``` 245 | ```` 246 | 247 | Then specify the custom template path in your `.env` file: 248 | 249 | ``` 250 | DOCUDOODLE_PROMPT_TEMPLATE=./path/to/template.md 251 | ``` 252 | 253 | ## Using Azure OpenAI 254 | 255 | To generate documentation using Azure OpenAI: 256 | 257 | 1. Set up your Azure configuration in the `.env` file: 258 | 259 | ``` 260 | AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com 261 | AZURE_OPENAI_API_KEY=your-api-key 262 | AZURE_OPENAI_DEPLOYMENT=your-deployment-id 263 | DOCUDOODLE_API_PROVIDER=azure 264 | ``` 265 | 266 | 2. Run the command: 267 | 268 | ``` 269 | php artisan docudoodle:generate 270 | ``` 271 | 272 | You can also specify Azure parameters directly in the command: 273 | 274 | ``` 275 | php artisan docudoodle:generate --api-provider=azure --azure-endpoint=https://your-resource.openai.azure.com --azure-deployment=your-deployment 276 | ``` 277 | 278 | ## Documentation Output Options 279 | 280 | By default, Docudoodle generates documentation in Markdown format in your specified output directory. 281 | 282 | ### Jira Integration 283 | 284 | Docudoodle can publish documentation directly to Jira as issues. To enable this: 285 | 286 | 1. Configure your Jira settings in `.env`: 287 | 288 | ``` 289 | DOCUDOODLE_JIRA_ENABLED=true 290 | JIRA_HOST=https://your-domain.atlassian.net 291 | JIRA_API_TOKEN=your-api-token 292 | JIRA_EMAIL=your-email@example.com 293 | JIRA_PROJECT_KEY=your-project-key 294 | JIRA_ISSUE_TYPE=Documentation 295 | ``` 296 | 297 | 2. Run the command with Jira enabled: 298 | 299 | ``` 300 | php artisan docudoodle:generate --jira 301 | ``` 302 | 303 | ### Confluence Integration 304 | 305 | Docudoodle can publish documentation directly to Confluence. To enable this: 306 | 307 | 1. Configure your Confluence settings in `.env`: 308 | 309 | ``` 310 | DOCUDOODLE_CONFLUENCE_ENABLED=true 311 | CONFLUENCE_HOST=https://your-domain.atlassian.net 312 | CONFLUENCE_API_TOKEN=your-api-token 313 | CONFLUENCE_EMAIL=your-email@example.com 314 | CONFLUENCE_SPACE_KEY=your-space-key 315 | CONFLUENCE_PARENT_PAGE_ID=optional-parent-page-id 316 | ``` 317 | 318 | 2. Run the command with Confluence enabled: 319 | 320 | ``` 321 | php artisan docudoodle:generate --confluence 322 | ``` 323 | 324 | ### Dependencies 325 | 326 | To use Jira or Confluence integration, make sure to install the Guzzle HTTP client: 327 | 328 | ``` 329 | composer require guzzlehttp/guzzle:^7.0 330 | ``` 331 | 332 | ### Command Options 333 | 334 | ``` 335 | php artisan docudoodle:generate 336 | --jira # Enable Jira documentation output 337 | --confluence # Enable Confluence documentation output 338 | --no-files # Disable file system documentation output 339 | ``` 340 | 341 | You can combine these options as needed. For example, to generate documentation in both Jira and Confluence but not in the file system: 342 | 343 | ``` 344 | php artisan docudoodle:generate --jira --confluence --no-files 345 | ``` 346 | 347 | ## Running Tests 348 | 349 | Want to make sure everything's working perfectly? Run the tests with: 350 | 351 | ``` 352 | ./vendor/bin/phpunit 353 | ``` 354 | 355 | Or if you're using Laravel's test runner: 356 | 357 | ``` 358 | php artisan test 359 | ``` 360 | 361 | ## License 362 | 363 | This project is licensed under the MIT License. Check out the LICENSE file for all the details. 364 | 365 | ## Contributing 366 | 367 | We'd love your help making Docudoodle even better! Feel free to submit a pull request or open an issue for any enhancements or bug fixes. Everyone's welcome! 🎉 368 | -------------------------------------------------------------------------------- /tests/CachingTest.php: -------------------------------------------------------------------------------- 1 | tempSourceDir = sys_get_temp_dir() . '/' . uniqid('docudoodle_test_source_'); 22 | $this->tempOutputDir = sys_get_temp_dir() . '/' . uniqid('docudoodle_test_output_'); 23 | mkdir($this->tempSourceDir, 0777, true); 24 | mkdir($this->tempOutputDir, 0777, true); 25 | 26 | // Default cache file location for setup 27 | $this->tempCacheFile = $this->tempOutputDir . '/.docudoodle_cache.json'; 28 | } 29 | 30 | protected function tearDown(): void 31 | { 32 | // Clean up temporary directories and files 33 | $this->deleteDirectory($this->tempSourceDir); 34 | $this->deleteDirectory($this->tempOutputDir); 35 | // Attempt to delete cache file if it exists outside output dir in some tests 36 | if ($this->tempCacheFile && file_exists($this->tempCacheFile) && strpos($this->tempCacheFile, $this->tempOutputDir) !== 0) { 37 | @unlink($this->tempCacheFile); 38 | } 39 | parent::tearDown(); 40 | } 41 | 42 | // Helper function to recursively delete a directory 43 | private function deleteDirectory(string $dir): void 44 | { 45 | if (!is_dir($dir)) { 46 | return; 47 | } 48 | $files = array_diff(scandir($dir), ['.', '..']); 49 | foreach ($files as $file) { 50 | (is_dir("$dir/$file")) ? $this->deleteDirectory("$dir/$file") : unlink("$dir/$file"); 51 | } 52 | rmdir($dir); 53 | } 54 | 55 | // Helper to create a dummy source file 56 | private function createSourceFile(string $relativePath, string $content): string 57 | { 58 | $fullPath = $this->tempSourceDir . '/' . $relativePath; 59 | $dir = dirname($fullPath); 60 | if (!is_dir($dir)) { 61 | mkdir($dir, 0777, true); 62 | } 63 | file_put_contents($fullPath, $content); 64 | return $fullPath; 65 | } 66 | 67 | // Helper to get Docudoodle instance with basic config 68 | private function getGenerator( 69 | bool $useCache = true, 70 | ?string $cacheFilePath = null, 71 | bool $forceRebuild = false, 72 | string $model = 'test-model', 73 | string $promptTemplatePath = __DIR__ . '/../../resources/templates/default-prompt.md' 74 | ): Docudoodle // Return a real instance now 75 | { 76 | // Use a very simple model/API key for testing 77 | return new Docudoodle( 78 | openaiApiKey: 'test-key', 79 | sourceDirs: [$this->tempSourceDir], 80 | outputDir: $this->tempOutputDir, 81 | model: $model, 82 | maxTokens: 100, 83 | allowedExtensions: ['php'], 84 | skipSubdirectories: [], 85 | apiProvider: 'openai', // Assume basic provider for test structure 86 | ollamaHost: 'localhost', 87 | ollamaPort: 5000, 88 | promptTemplate: $promptTemplatePath, 89 | useCache: $useCache, 90 | cacheFilePath: $cacheFilePath ?? $this->tempCacheFile, // Pass explicitly 91 | forceRebuild: $forceRebuild 92 | ); 93 | } 94 | 95 | // --- Test Cases --- 96 | 97 | public function testSkipsUnchangedFiles(): void 98 | { 99 | // Define Mocks for curl functions within Docudoodle namespace 100 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 101 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 102 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 103 | // Mock curl_close to do nothing 104 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 105 | // Mock curl_init to return a dummy resource (or handle) 106 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); // Return a real dummy handle 107 | // Mock curl_setopt_array/curl_setopt to do nothing (or check options if needed) 108 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 109 | 110 | // Configure mock responses 111 | $mockApiResponse = json_encode([ 112 | 'choices' => [ 113 | ['message' => ['content' => 'Mocked AI Response']] 114 | ] 115 | ]); 116 | $curlExecMock->expects($this->any())->willReturn($mockApiResponse); 117 | $curlErrnoMock->expects($this->any())->willReturn(0); 118 | $curlErrorMock->expects($this->any())->willReturn(''); 119 | 120 | // 1. Create source file 121 | $sourcePath = $this->createSourceFile('test.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/test.md'; // Correct path calculation 123 | $cachePath = $this->tempCacheFile; 124 | 125 | // 2. Run generator (first run) 126 | $generator1 = $this->getGenerator(); 127 | $generator1->generate(); 128 | 129 | // 3. Assert doc file exists, cache file exists 130 | $this->assertFileExists($docPath, 'Documentation file should be created on first run.'); 131 | $this->assertFileExists($cachePath, 'Cache file should be created on first run.'); 132 | $initialDocModTime = filemtime($docPath); 133 | 134 | // Wait briefly to ensure file modification times can differ 135 | usleep(10000); // 10ms should be enough 136 | 137 | // 4. Run generator again 138 | $generator2 = $this->getGenerator(); // Get a fresh instance 139 | // Capture output to check for "Skipping" message 140 | ob_start(); 141 | $generator2->generate(); 142 | $output = ob_get_clean(); 143 | 144 | // 5. Assert generator indicates skipping 145 | $this->assertStringContainsString('Skipping unchanged file', $output, 'Generator output should indicate skipping.'); 146 | $this->assertStringContainsString($sourcePath, $output, 'Generator output should mention the skipped file path.'); 147 | 148 | // 6. Assert doc file timestamp hasn't changed 149 | $this->assertFileExists($docPath); // Make sure it wasn't deleted 150 | $finalDocModTime = filemtime($docPath); 151 | $this->assertEquals($initialDocModTime, $finalDocModTime, 'Documentation file modification time should not change on second run.'); 152 | } 153 | 154 | public function testReprocessesChangedFiles(): void 155 | { 156 | // Define Mocks for curl functions 157 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 158 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 159 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 160 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 161 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 162 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 163 | 164 | $curlExecMock->expects($this->atLeastOnce()) 165 | ->willReturnCallback(function() { 166 | return json_encode([ 167 | 'choices' => [ 168 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 169 | ] 170 | ]); 171 | }); 172 | $curlErrnoMock->expects($this->any())->willReturn(0); 173 | $curlErrorMock->expects($this->any())->willReturn(''); 174 | 175 | // 1. Create source file 176 | $sourcePath = $this->createSourceFile('test.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/test.md'; 178 | $cachePath = $this->tempCacheFile; 179 | $initialHash = sha1_file($sourcePath); 180 | 181 | // 2. Run generator 182 | $generator1 = $this->getGenerator(); 183 | $generator1->generate(); 184 | 185 | // 3. Get initial state 186 | $this->assertFileExists($docPath); 187 | $this->assertFileExists($cachePath); 188 | $initialDocModTime = filemtime($docPath); 189 | $initialCacheData = json_decode(file_get_contents($cachePath), true); 190 | $this->assertEquals($initialHash, $initialCacheData[$sourcePath] ?? null, 'Initial cache should contain correct hash.'); 191 | 192 | // Wait briefly 193 | usleep(10000); 194 | 195 | // 4. Modify source file 196 | $this->createSourceFile('test.php', 'assertNotEquals($initialHash, $newHash, 'File hashes should differ after modification.'); 199 | 200 | // Update mock response for second run if needed (optional, depends on assertion needs) 201 | $curlExecMock->expects($this->exactly(2))->willReturnOnConsecutiveCalls( 202 | json_encode([ 203 | 'choices' => [ 204 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 205 | ] 206 | ]), 207 | json_encode([ 208 | 'choices' => [ 209 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 210 | ] 211 | ]) 212 | ); 213 | 214 | // 5. Run generator again 215 | $generator2 = $this->getGenerator(); 216 | // Capture output to ensure it DOES NOT say skipping 217 | ob_start(); 218 | $generator2->generate(); 219 | $output = ob_get_clean(); 220 | 221 | // Assert it didn't skip 222 | $this->assertStringNotContainsString('Skipping unchanged file', $output, 'Generator output should NOT indicate skipping.'); 223 | $this->assertStringContainsString('Generating documentation', $output, 'Generator output should indicate generation.'); 224 | 225 | // 6. Assert doc file mod time/hash changed 226 | $this->assertFileExists($docPath); 227 | clearstatcache(); // Clear stat cache before checking filemtime again 228 | $finalDocModTime = filemtime($docPath); 229 | $this->assertGreaterThan($initialDocModTime, $finalDocModTime, 'Documentation file modification time should update on second run.'); 230 | 231 | // 7. Assert cache file content updated with new hash 232 | $this->assertFileExists($cachePath); 233 | $finalCacheData = json_decode(file_get_contents($cachePath), true); 234 | $this->assertEquals($newHash, $finalCacheData[$sourcePath] ?? null, 'Cache should be updated with the new hash.'); 235 | $this->assertEquals($initialCacheData['_config_hash'] ?? null, $finalCacheData['_config_hash'] ?? null, 'Config hash should not change.'); 236 | } 237 | 238 | public function testProcessesNewFiles(): void 239 | { 240 | // Define Mocks for curl functions 241 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 242 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 243 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 244 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 245 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 246 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 247 | 248 | $curlExecMock->expects($this->atLeastOnce()) 249 | ->willReturnCallback(function() { 250 | return json_encode([ 251 | 'choices' => [ 252 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 253 | ] 254 | ]); 255 | }); 256 | $curlErrnoMock->expects($this->any())->willReturn(0); 257 | $curlErrorMock->expects($this->any())->willReturn(''); 258 | 259 | // 1. Create source file A 260 | $sourcePathA = $this->createSourceFile('fileA.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/fileA.md'; 262 | $cachePath = $this->tempCacheFile; 263 | $hashA = sha1_file($sourcePathA); 264 | 265 | // 2. Run generator (first run) 266 | $generator1 = $this->getGenerator(); 267 | $generator1->generate(); 268 | 269 | // 3. Assert doc A exists 270 | $this->assertFileExists($docPathA, 'Doc A should exist after first run.'); 271 | $this->assertFileExists($cachePath, 'Cache should exist after first run.'); 272 | $initialDocAModTime = filemtime($docPathA); 273 | $cacheData1 = json_decode(file_get_contents($cachePath), true); 274 | $this->assertEquals($hashA, $cacheData1[$sourcePathA] ?? null, 'Cache should contain hash for file A.'); 275 | $this->assertCount(2, $cacheData1, 'Cache should have 2 entries (config + file A).'); // Config hash + File A 276 | 277 | // Wait briefly 278 | usleep(10000); 279 | 280 | // 4. Create source file B 281 | $sourcePathB = $this->createSourceFile('fileB.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/fileB.md'; 283 | $hashB = sha1_file($sourcePathB); 284 | 285 | // 5. Run generator again 286 | $generator2 = $this->getGenerator(); 287 | // Capture output 288 | ob_start(); 289 | $generator2->generate(); 290 | $output = ob_get_clean(); 291 | 292 | // Assert file A was skipped and file B was generated 293 | $this->assertStringContainsString("Skipping unchanged file: {$sourcePathA}", $output, 'Generator should skip file A.'); 294 | $this->assertStringContainsString("Generating documentation for {$sourcePathB}", $output, 'Generator should generate file B.'); 295 | 296 | // 6. Assert doc B exists 297 | $this->assertFileExists($docPathB, 'Doc B should exist after second run.'); 298 | 299 | // 7. Assert doc A was likely skipped (check mod time?) 300 | clearstatcache(); 301 | $finalDocAModTime = filemtime($docPathA); 302 | $this->assertEquals($initialDocAModTime, $finalDocAModTime, 'Doc A modification time should not change.'); 303 | 304 | // 8. Assert cache contains both A and B 305 | $this->assertFileExists($cachePath); 306 | $cacheData2 = json_decode(file_get_contents($cachePath), true); 307 | $this->assertEquals($hashA, $cacheData2[$sourcePathA] ?? null, 'Cache should still contain correct hash for file A.'); 308 | $this->assertEquals($hashB, $cacheData2[$sourcePathB] ?? null, 'Cache should now contain hash for file B.'); 309 | $this->assertCount(3, $cacheData2, 'Cache should have 3 entries (config + file A + file B).'); // Config hash + File A + File B 310 | $this->assertEquals($cacheData1['_config_hash'] ?? null, $cacheData2['_config_hash'] ?? null, 'Config hash should not change.'); 311 | } 312 | 313 | public function testOrphanCleanup(): void 314 | { 315 | // Define Mocks for curl functions 316 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 317 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 318 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 319 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 320 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 321 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 322 | 323 | $curlExecMock->expects($this->atLeastOnce()) 324 | ->willReturnCallback(function() { 325 | return json_encode([ 326 | 'choices' => [ 327 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 328 | ] 329 | ]); 330 | }); 331 | $curlErrnoMock->expects($this->any())->willReturn(0); 332 | $curlErrorMock->expects($this->any())->willReturn(''); 333 | 334 | // 1. Create source files A and B 335 | $sourcePathA = $this->createSourceFile('fileA.php', 'createSourceFile('fileB.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/fileA.md'; 338 | $docPathB = $this->tempOutputDir . '/' . basename($this->tempSourceDir) . '/fileB.md'; 339 | $cachePath = $this->tempCacheFile; 340 | $hashA = sha1_file($sourcePathA); 341 | $hashB = sha1_file($sourcePathB); 342 | 343 | // 2. Run generator 344 | $generator1 = $this->getGenerator(); 345 | $generator1->generate(); 346 | 347 | // 3. Assert docs A and B exist, cache contains A and B 348 | $this->assertFileExists($docPathA, 'Doc A should exist initially.'); 349 | $this->assertFileExists($docPathB, 'Doc B should exist initially.'); 350 | $this->assertFileExists($cachePath, 'Cache should exist initially.'); 351 | $cacheData1 = json_decode(file_get_contents($cachePath), true); 352 | $this->assertArrayHasKey($sourcePathA, $cacheData1); 353 | $this->assertArrayHasKey($sourcePathB, $cacheData1); 354 | $this->assertEquals($hashA, $cacheData1[$sourcePathA]); 355 | $this->assertEquals($hashB, $cacheData1[$sourcePathB]); 356 | $this->assertCount(3, $cacheData1); // config + A + B 357 | 358 | // 4. Delete source file A 359 | unlink($sourcePathA); 360 | $this->assertFileDoesNotExist($sourcePathA, 'Source file A should be deleted.'); 361 | 362 | // Wait briefly 363 | usleep(10000); 364 | 365 | // 5. Run generator again 366 | $generator2 = $this->getGenerator(); 367 | ob_start(); 368 | $generator2->generate(); 369 | $output = ob_get_clean(); 370 | 371 | // 7. Assert output indicates cleanup and deletion 372 | $this->assertStringContainsString('Cleaning up documentation for deleted source files', $output, 'Generator should mention cleanup.'); 373 | $this->assertStringContainsString("Deleting orphan documentation: {$docPathA}", $output, 'Generator should mention deleting orphan doc A.'); 374 | $this->assertStringContainsString("Skipping unchanged file: {$sourcePathB}", $output, 'Generator should skip file B.'); 375 | 376 | // 6. Assert doc A does not exist 377 | $this->assertFileDoesNotExist($docPathA, 'Doc A should be deleted after cleanup.'); 378 | 379 | // 8. Assert doc B still exists 380 | $this->assertFileExists($docPathB, 'Doc B should still exist.'); 381 | 382 | // 9. Assert cache contains only B 383 | $this->assertFileExists($cachePath, 'Cache should still exist.'); 384 | $cacheData2 = json_decode(file_get_contents($cachePath), true); 385 | $this->assertArrayNotHasKey($sourcePathA, $cacheData2, 'Cache should not contain file A after cleanup.'); 386 | $this->assertArrayHasKey($sourcePathB, $cacheData2, 'Cache should still contain file B.'); 387 | $this->assertEquals($hashB, $cacheData2[$sourcePathB], 'Cache should have correct hash for file B.'); 388 | $this->assertCount(2, $cacheData2, 'Cache should have 2 entries (config + file B).'); // config + B 389 | $this->assertEquals($cacheData1['_config_hash'] ?? null, $cacheData2['_config_hash'] ?? null, 'Config hash should not change during orphan cleanup.'); 390 | } 391 | 392 | public function testConfigurationChangeInvalidation(): void 393 | { 394 | // Define Mocks for curl functions 395 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 396 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 397 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 398 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 399 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 400 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 401 | 402 | $curlExecMock->expects($this->atLeastOnce()) 403 | ->willReturnCallback(function() { 404 | return json_encode([ 405 | 'choices' => [ 406 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 407 | ] 408 | ]); 409 | }); 410 | $curlErrnoMock->expects($this->any())->willReturn(0); 411 | $curlErrorMock->expects($this->any())->willReturn(''); 412 | 413 | // 1. Create source file 414 | $sourcePath = $this->createSourceFile('test.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/test.md'; 416 | $cachePath = $this->tempCacheFile; 417 | $fileHash = sha1_file($sourcePath); 418 | 419 | // 2. Run generator with config X (model-v1) 420 | $generator1 = $this->getGenerator(model: 'model-v1'); 421 | ob_start(); // Capture initial output to check later 422 | $generator1->generate(); 423 | ob_end_clean(); // Discard initial output for now 424 | 425 | // 3. Get initial state 426 | $this->assertFileExists($docPath, 'Doc file should exist after first run.'); 427 | $this->assertFileExists($cachePath, 'Cache file should exist after first run.'); 428 | $initialDocModTime = filemtime($docPath); 429 | $cacheData1 = json_decode(file_get_contents($cachePath), true); 430 | $initialConfigHash = $cacheData1['_config_hash'] ?? null; 431 | $this->assertNotNull($initialConfigHash, 'Initial config hash should be set.'); 432 | $this->assertEquals($fileHash, $cacheData1[$sourcePath] ?? null, 'Initial cache should have file hash.'); 433 | 434 | // Wait briefly 435 | usleep(10000); 436 | 437 | // 4. Run generator again with config Y (model-v2) 438 | // Pass a different model name to trigger config hash change 439 | $generator2 = $this->getGenerator(model: 'model-v2'); 440 | ob_start(); 441 | $generator2->generate(); 442 | $output = ob_get_clean(); 443 | 444 | // 5. Assert output indicates invalidation and reprocessing 445 | $this->assertStringContainsString('Configuration changed or cache invalidated', $output, 'Generator should indicate config change.'); 446 | $this->assertStringContainsString('Forcing full documentation rebuild', $output, 'Generator should indicate forcing rebuild.'); 447 | $this->assertStringContainsString("Generating documentation for {$sourcePath}", $output, 'Generator should re-generate the file.'); 448 | $this->assertStringNotContainsString('Skipping unchanged file', $output, 'Generator should not skip the file despite unchanged content.'); 449 | 450 | // 6. Assert doc file mod time/hash changed (indicating reprocessing) 451 | $this->assertFileExists($docPath); // Still exists 452 | clearstatcache(); 453 | $finalDocModTime = filemtime($docPath); 454 | $this->assertGreaterThan($initialDocModTime, $finalDocModTime, 'Doc file modification time should update due to reprocessing.'); 455 | 456 | // 7. Assert cache config hash updated 457 | $this->assertFileExists($cachePath); 458 | $cacheData2 = json_decode(file_get_contents($cachePath), true); 459 | $finalConfigHash = $cacheData2['_config_hash'] ?? null; 460 | $this->assertNotNull($finalConfigHash, 'Final config hash should be set.'); 461 | $this->assertNotEquals($initialConfigHash, $finalConfigHash, 'Config hash should change after config modification.'); 462 | 463 | // 8. Assert file hash is still present (re-added after reprocessing) 464 | $this->assertEquals($fileHash, $cacheData2[$sourcePath] ?? null, 'Cache should contain correct file hash after reprocessing.'); 465 | $this->assertCount(2, $cacheData2); // config hash + file hash 466 | } 467 | 468 | public function testForceRebuildFlag(): void 469 | { 470 | // Define Mocks for curl functions 471 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 472 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 473 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 474 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 475 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 476 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 477 | 478 | // Configure mock responses with a callback to include a timestamp 479 | $curlExecMock->expects($this->atLeastOnce()) // Expect at least one call 480 | ->willReturnCallback(function() { 481 | return json_encode([ 482 | 'choices' => [ 483 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 484 | ] 485 | ]); 486 | }); 487 | $curlErrnoMock->expects($this->any())->willReturn(0); 488 | $curlErrorMock->expects($this->any())->willReturn(''); 489 | 490 | // 1. Create source file 491 | $sourcePath = $this->createSourceFile('test.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/test.md'; 493 | $cachePath = $this->tempCacheFile; 494 | $fileHash = sha1_file($sourcePath); 495 | 496 | // 2. Run generator normally 497 | $generator1 = $this->getGenerator(); 498 | $generator1->generate(); 499 | 500 | // 3. Get initial state 501 | $this->assertFileExists($docPath, 'Doc file should exist after first run.'); 502 | $initialDocContent = file_get_contents($docPath); // Get initial content 503 | $this->assertFileExists($cachePath, 'Cache file should exist after first run.'); 504 | $cacheData1 = json_decode(file_get_contents($cachePath), true); 505 | $this->assertEquals($fileHash, $cacheData1[$sourcePath] ?? null); 506 | 507 | // Wait briefly 508 | usleep(10000); 509 | 510 | // 4. Run generator again with forceRebuild = true 511 | $generator2 = $this->getGenerator(forceRebuild: true); 512 | ob_start(); 513 | $generator2->generate(); 514 | $output = ob_get_clean(); 515 | 516 | // 5. Assert output indicates rebuild and generation (not skipping) 517 | $this->assertStringContainsString('Cache will be rebuilt', $output, 'Generator should indicate cache rebuild.'); 518 | $this->assertStringContainsString("Generating documentation for {$sourcePath}", $output, 'Generator should re-generate the file on force rebuild.'); 519 | $this->assertStringNotContainsString('Skipping unchanged file', $output, 'Generator should not skip the file on force rebuild.'); 520 | 521 | // 6. Assert doc file content changed (indicating reprocessing) 522 | $this->assertFileExists($docPath); 523 | clearstatcache(); // Might not be strictly needed for content check, but good practice 524 | $finalDocContent = file_get_contents($docPath); // Get final content 525 | $this->assertNotEquals($initialDocContent, $finalDocContent, 'Doc file content should change on force rebuild due to mock timestamp.'); 526 | 527 | // 7. Assert cache is updated correctly (still contains the hash) 528 | $this->assertFileExists($cachePath); 529 | $cacheData2 = json_decode(file_get_contents($cachePath), true); 530 | $this->assertEquals($fileHash, $cacheData2[$sourcePath] ?? null, 'Cache should still contain correct file hash after force rebuild.'); 531 | $this->assertArrayHasKey('_config_hash', $cacheData2, 'Cache should contain config hash after force rebuild.'); 532 | $this->assertCount(2, $cacheData2); // config hash + file hash 533 | } 534 | 535 | public function testCacheDefaultLocation(): void 536 | { 537 | // Define Mocks for curl functions (only need to run once) 538 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 539 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 540 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 541 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 542 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 543 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 544 | 545 | $mockApiResponse = json_encode([ 546 | 'choices' => [ 547 | ['message' => ['content' => 'Mocked AI Response']] 548 | ] 549 | ]); 550 | $curlExecMock->expects($this->once())->willReturn($mockApiResponse); 551 | $curlErrnoMock->expects($this->any())->willReturn(0); 552 | $curlErrorMock->expects($this->any())->willReturn(''); 553 | 554 | // 1. Create source file 555 | $this->createSourceFile('test.php', 'tempOutputDir . '/.docudoodle_cache.json'; 557 | 558 | // 2. Run generator - Pass null for cacheFilePath to trigger default logic 559 | // Note: The getGenerator helper itself defaults to $this->tempCacheFile if the arg is null, 560 | // so we MUST pass null explicitly here to override the helper's internal default. 561 | $generator = $this->getGenerator(cacheFilePath: null); 562 | $generator->generate(); 563 | 564 | // 3. Assert cache file exists at the default location 565 | $this->assertFileExists($expectedDefaultCachePath, 'Cache file should be created at the default location.'); 566 | } 567 | 568 | public function testCacheCustomLocationConfig(): void 569 | { 570 | // Define Mocks for curl functions 571 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 572 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 573 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 574 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 575 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 576 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 577 | 578 | $mockApiResponse = json_encode([ 579 | 'choices' => [ 580 | ['message' => ['content' => 'Mocked AI Response']] 581 | ] 582 | ]); 583 | $curlExecMock->expects($this->once())->willReturn($mockApiResponse); 584 | $curlErrnoMock->expects($this->any())->willReturn(0); 585 | $curlErrorMock->expects($this->any())->willReturn(''); 586 | 587 | // 1. Set custom cache path (outside output dir, but still in temp) 588 | $customCachePath = sys_get_temp_dir() . '/' . uniqid('docudoodle_custom_cache_') . '.json'; 589 | $this->tempCacheFile = $customCachePath; // Update teardown target 590 | $this->assertFileDoesNotExist($customCachePath, 'Custom cache file should not exist yet.'); 591 | 592 | // 2. Create source file 593 | $this->createSourceFile('test.php', 'getGenerator(cacheFilePath: $customCachePath); 597 | $generator->generate(); 598 | 599 | // 4. Assert cache file exists at custom path 600 | $this->assertFileExists($customCachePath, 'Cache file should be created at the custom location.'); 601 | // Also assert it wasn't created in the default location 602 | $defaultCachePath = $this->tempOutputDir . '/.docudoodle_cache.json'; 603 | $this->assertFileDoesNotExist($defaultCachePath, 'Cache file should NOT be created at the default location when custom path is given.'); 604 | } 605 | 606 | // Note: Testing command-line override requires a different approach, perhaps testing GenerateDocsCommand itself. 607 | 608 | public function testCacheDisabled(): void 609 | { 610 | // Define Mocks for curl functions 611 | $curlExecMock = $this->getFunctionMock('Docudoodle', 'curl_exec'); 612 | $curlErrnoMock = $this->getFunctionMock('Docudoodle', 'curl_errno'); 613 | $curlErrorMock = $this->getFunctionMock('Docudoodle', 'curl_error'); 614 | $this->getFunctionMock('Docudoodle', 'curl_close')->expects($this->any()); 615 | $this->getFunctionMock('Docudoodle', 'curl_init')->expects($this->any())->willReturn(curl_init()); 616 | $this->getFunctionMock('Docudoodle', 'curl_setopt_array')->expects($this->any()); 617 | 618 | // Configure mock response with timestamp to check reprocessing 619 | $curlExecMock->expects($this->exactly(2)) // Expect two calls since cache is disabled 620 | ->willReturnCallback(function() { 621 | return json_encode([ 622 | 'choices' => [ 623 | ['message' => ['content' => 'Mocked AI Response @ ' . microtime(true)]] 624 | ] 625 | ]); 626 | }); 627 | $curlErrnoMock->expects($this->any())->willReturn(0); 628 | $curlErrorMock->expects($this->any())->willReturn(''); 629 | 630 | // 1. Create source file 631 | $sourcePath = $this->createSourceFile('test.php', 'tempOutputDir . '/' . basename($this->tempSourceDir) . '/test.md'; 633 | $defaultCachePath = $this->tempOutputDir . '/.docudoodle_cache.json'; 634 | 635 | // 2. Run generator with useCache = false 636 | $generator1 = $this->getGenerator(useCache: false); 637 | $generator1->generate(); 638 | 639 | // 3. Assert cache file does NOT exist 640 | $this->assertFileDoesNotExist($defaultCachePath, 'Cache file should not be created when cache is disabled.'); 641 | $this->assertFileExists($docPath, 'Doc file should be created even with cache disabled.'); 642 | $initialDocContent = file_get_contents($docPath); 643 | 644 | // Wait briefly 645 | usleep(10000); 646 | 647 | // 4. Run generator again with useCache = false 648 | $generator2 = $this->getGenerator(useCache: false); 649 | ob_start(); 650 | $generator2->generate(); 651 | $output = ob_get_clean(); 652 | 653 | // 5. Assert cache file still does NOT exist 654 | $this->assertFileDoesNotExist($defaultCachePath, 'Cache file should still not exist on second run.'); 655 | 656 | // 6. Assert it processed again (check doc content change & output) 657 | $this->assertStringNotContainsString('Skipping unchanged file', $output, 'Generator should not skip when cache disabled.'); 658 | $this->assertStringContainsString('Generating documentation', $output, 'Generator should generate again when cache disabled.'); 659 | clearstatcache(); 660 | $finalDocContent = file_get_contents($docPath); 661 | $this->assertNotEquals($initialDocContent, $finalDocContent, 'Doc file content should change on second run when cache disabled.'); 662 | } 663 | } 664 | -------------------------------------------------------------------------------- /src/Docudoodle.php: -------------------------------------------------------------------------------- 1 | [], 29 | 'controllers' => [], 30 | 'models' => [], 31 | 'relationships' => [], 32 | 'imports' => [], 33 | ]; 34 | /** 35 | * In-memory cache of file hashes. 36 | */ 37 | private array $hashMap = []; 38 | /** 39 | * List of source file paths encountered during the run. 40 | */ 41 | private array $encounteredFiles = []; 42 | 43 | /** 44 | * Constructor for Docudoodle 45 | * 46 | * @param string $apiKey OpenAI/Claude/Gemini API key (not needed for Ollama) 47 | * @param array $sourceDirs Directories to process 48 | * @param string $outputDir Directory for generated documentation 49 | * @param string $model AI model to use 50 | * @param int $maxTokens Maximum tokens for API calls 51 | * @param array $allowedExtensions File extensions to process 52 | * @param array $skipSubdirectories Subdirectories to skip 53 | * @param string $apiProvider API provider to use (default: 'openai') 54 | * @param string $ollamaHost Ollama host (default: 'localhost') 55 | * @param int $ollamaPort Ollama port (default: 5000) 56 | * @param string $promptTemplate Path to prompt template markdown file 57 | * @param bool $useCache Whether to use the caching mechanism 58 | * @param ?string $cacheFilePath Specific path to the cache file (null for default) 59 | * @param bool $forceRebuild Force regeneration ignoring cache 60 | * @param string $azureEndpoint Azure OpenAI endpoint URL (default: "") 61 | * @param string $azureDeployment Azure OpenAI deployment ID (default: "") 62 | * @param string $azureApiVersion Azure OpenAI API version (default: "2023-05-15") 63 | */ 64 | public function __construct( 65 | private string $openaiApiKey = "", 66 | private array $sourceDirs = ["app/", "config/", "routes/", "database/"], 67 | private string $outputDir = "documentation/", 68 | private string $model = "gpt-4o-mini", 69 | private int $maxTokens = 10000, 70 | private array $allowedExtensions = ["php", "yaml", "yml"], 71 | private array $skipSubdirectories = [ 72 | "vendor/", 73 | "node_modules/", 74 | "tests/", 75 | "cache/", 76 | ], 77 | private string $apiProvider = "openai", 78 | private string $ollamaHost = "localhost", 79 | private int $ollamaPort = 5000, 80 | private string $promptTemplate = __DIR__ . "/../resources/templates/default-prompt.md", 81 | private bool $useCache = true, 82 | private ?string $cacheFilePath = null, 83 | private bool $forceRebuild = false, 84 | private string $azureEndpoint = "", 85 | private string $azureDeployment = "", 86 | private string $azureApiVersion = "2023-05-15", 87 | private array $jiraConfig = [], 88 | private array $confluenceConfig = [] 89 | ) 90 | { 91 | // Ensure the cache file path is set if using cache and no specific path is provided 92 | if ($this->useCache && empty($this->cacheFilePath)) { 93 | $this->cacheFilePath = rtrim($this->outputDir, '/') . '/.docudoodle_cache.json'; 94 | } 95 | 96 | // Initialize Jira service if enabled 97 | if (!empty($jiraConfig) && $jiraConfig['enabled']) { 98 | $this->jiraService = new Services\JiraDocumentationService($jiraConfig); 99 | } 100 | 101 | // Initialize Confluence service if enabled 102 | if (!empty($confluenceConfig) && $confluenceConfig['enabled']) { 103 | $this->confluenceService = new Services\ConfluenceDocumentationService($confluenceConfig); 104 | } 105 | } 106 | 107 | /** 108 | * Main method to execute the documentation generation 109 | */ 110 | public function generate(): void 111 | { 112 | // Ensure output directory exists 113 | $this->ensureDirectoryExists($this->outputDir); 114 | 115 | // Initialize cache and encountered files list 116 | $this->hashMap = []; 117 | $this->encounteredFiles = []; 118 | 119 | // Load existing hash map and check config hash if caching is enabled 120 | if ($this->useCache && !$this->forceRebuild) { 121 | $this->hashMap = $this->loadHashMap(); 122 | $currentConfigHash = $this->calculateConfigHash(); 123 | $storedConfigHash = $this->hashMap['_config_hash'] ?? null; 124 | 125 | if ($currentConfigHash !== $storedConfigHash) { 126 | echo "Configuration changed or cache invalidated. Forcing full documentation rebuild.\n"; 127 | // Clear file hashes but keep the config hash key for updating later 128 | $fileHashes = $this->hashMap; 129 | unset($fileHashes['_config_hash']); 130 | $this->hashMap = ['_config_hash' => $currentConfigHash]; 131 | // Mark for rebuild internally by setting forceRebuild temporarily 132 | // This ensures config hash is updated even if generate() is interrupted 133 | $this->forceRebuild = true; // Temporarily force rebuild for this run 134 | } else { 135 | echo "Using existing cache file: {$this->cacheFilePath}\n"; 136 | } 137 | } 138 | 139 | // If forcing rebuild (either via option or config change), ensure config hash is set 140 | if ($this->useCache && $this->forceRebuild) { 141 | $this->hashMap['_config_hash'] = $this->calculateConfigHash(); 142 | echo "Cache will be rebuilt.\n"; 143 | } 144 | 145 | // Process each source directory 146 | foreach ($this->sourceDirs as $sourceDir) { 147 | if (file_exists($sourceDir)) { 148 | echo "Processing directory: {$sourceDir}\n"; 149 | $this->processDirectory($sourceDir); 150 | } else { 151 | echo "Directory not found: {$sourceDir}\n"; 152 | } 153 | } 154 | 155 | // --- Start Orphan Cleanup --- 156 | if ($this->useCache) { 157 | $cachedFiles = array_keys(array_filter($this->hashMap, fn($key) => $key !== '_config_hash', ARRAY_FILTER_USE_KEY)); 158 | $orphans = array_diff($cachedFiles, $this->encounteredFiles); 159 | 160 | if (!empty($orphans)) { 161 | echo "Cleaning up documentation for deleted source files...\n"; 162 | $outputDirPrefixed = rtrim($this->outputDir, "/") . "/"; 163 | 164 | foreach ($orphans as $orphanSourcePath) { 165 | // Find the original base source directory for the orphan 166 | $baseSourceDir = null; 167 | foreach ($this->sourceDirs as $dir) { 168 | // Ensure consistent directory separators and trailing slash for comparison 169 | $normalizedDir = rtrim(str_replace('\\', '/', $dir), '/') . '/'; 170 | $normalizedOrphanPath = str_replace('\\', '/', $orphanSourcePath); 171 | 172 | if (strpos($normalizedOrphanPath, $normalizedDir) === 0) { 173 | $baseSourceDir = $dir; 174 | break; 175 | } 176 | } 177 | 178 | if ($baseSourceDir) { 179 | $relPath = substr($orphanSourcePath, strlen(rtrim($baseSourceDir, '/')) + 1); 180 | $sourceDirName = basename(rtrim($baseSourceDir, "/")); 181 | $fullRelPath = $sourceDirName . "/" . $relPath; 182 | $relDir = dirname($fullRelPath); 183 | $fileName = pathinfo($relPath, PATHINFO_FILENAME); 184 | $docPath = $outputDirPrefixed . $relDir . "/" . $fileName . ".md"; 185 | 186 | if (file_exists($docPath)) { 187 | echo "Deleting orphan documentation: {$docPath}\n"; 188 | @unlink($docPath); // Use @ to suppress errors if deletion fails 189 | } 190 | } else { 191 | echo "Warning: Could not determine source directory for orphan path: {$orphanSourcePath}\n"; 192 | } 193 | 194 | // Remove orphan from the hash map regardless 195 | unset($this->hashMap[$orphanSourcePath]); 196 | } 197 | } 198 | } 199 | // --- End Orphan Cleanup --- 200 | 201 | // Make sure the index is fully up to date 202 | $this->finalizeDocumentationIndex(); 203 | 204 | // Save the updated hash map if caching is enabled 205 | if ($this->useCache) { 206 | $this->saveHashMap($this->hashMap); 207 | } 208 | 209 | echo "\nDocumentation generation complete! Files are available in the '{$this->outputDir}' directory.\n"; 210 | } 211 | 212 | /** 213 | * Ensure the output directory exists 214 | */ 215 | private function ensureDirectoryExists($directoryPath): void 216 | { 217 | if (!file_exists($directoryPath)) { 218 | mkdir($directoryPath, 0755, true); 219 | } 220 | } 221 | 222 | /** 223 | * Load the hash map from the cache file. 224 | * 225 | * @return array The loaded hash map or empty array on failure/not found. 226 | */ 227 | private function loadHashMap(): array 228 | { 229 | if (!$this->useCache || !$this->cacheFilePath || !file_exists($this->cacheFilePath)) { 230 | return []; 231 | } 232 | 233 | try { 234 | $content = file_get_contents($this->cacheFilePath); 235 | $map = json_decode($content, true); 236 | return is_array($map) ? $map : []; 237 | } catch (Exception $e) { 238 | echo "Warning: Could not read or decode cache file: {$this->cacheFilePath} - {$e->getMessage()}\n"; 239 | return []; 240 | } 241 | } 242 | 243 | /** 244 | * Calculate a hash representing the current configuration relevant to caching. 245 | * 246 | * @return string The configuration hash. 247 | */ 248 | private function calculateConfigHash(): string 249 | { 250 | $realTemplatePath = realpath($this->promptTemplate) ?: $this->promptTemplate; // Use realpath or fallback 251 | $configData = [ 252 | 'model' => $this->model, 253 | 'apiProvider' => $this->apiProvider, 254 | 'promptTemplatePath' => $realTemplatePath, // Use normalized path 255 | 'promptTemplateContent' => file_exists($this->promptTemplate) ? sha1_file($this->promptTemplate) : 'template_not_found' 256 | ]; 257 | return sha1(json_encode($configData)); 258 | } 259 | 260 | /** 261 | * Process all files in directory recursively 262 | */ 263 | private function processDirectory($baseDir): void 264 | { 265 | $baseDir = rtrim($baseDir, "/"); 266 | 267 | $iterator = new RecursiveIteratorIterator( 268 | new RecursiveDirectoryIterator( 269 | $baseDir, 270 | RecursiveDirectoryIterator::SKIP_DOTS 271 | ) 272 | ); 273 | 274 | foreach ($iterator as $file) { 275 | // Skip directories 276 | if ($file->isDir()) { 277 | continue; 278 | } 279 | 280 | $sourcePath = $file->getPathname(); 281 | $dirName = basename(dirname($sourcePath)); 282 | $fileName = $file->getBasename(); 283 | 284 | // Skip hidden files and directories 285 | if (strpos($fileName, ".") === 0 || strpos($dirName, ".") === 0) { 286 | continue; 287 | } 288 | 289 | // Calculate relative path from the source directory 290 | $relFilePath = substr($sourcePath, strlen($baseDir) + 1); 291 | 292 | // Check if parent directory should be processed 293 | $relDirPath = dirname($relFilePath); 294 | 295 | if (!$this->shouldProcessDirectory($relDirPath, $sourcePath)) { 296 | continue; 297 | } 298 | 299 | // Record encountered file 300 | $this->encounteredFiles[] = $sourcePath; 301 | 302 | $this->createDocumentationFile($sourcePath, $relFilePath, $baseDir); 303 | } 304 | } 305 | 306 | /** 307 | * Check if directory should be processed based on allowed subdirectories 308 | */ 309 | private function shouldProcessDirectory($dirPath, $relFilePath): bool 310 | { 311 | // Normalize directory path for comparison 312 | $dirPath = rtrim($dirPath, "/") . "/"; 313 | $relFilePath = rtrim(dirname($relFilePath), "/") . "/"; 314 | 315 | // Check if directory or any parent directory is in the skip list 316 | foreach ($this->skipSubdirectories as $skipDir) { 317 | 318 | $skipDir = rtrim($skipDir, "/") . "/"; 319 | // Check if this directory is a subdirectory of a skipped directory 320 | // or if it matches exactly a skipped directory 321 | if (strpos($dirPath, $skipDir) === 0 || $dirPath === $skipDir) { 322 | return false; 323 | } 324 | 325 | if (preg_match('#' . str_replace('*', '.*?', $skipDir) . '#', $relFilePath)) { 326 | return false; 327 | } 328 | 329 | // Also check if any segment of the path matches a skipped directory 330 | $pathParts = explode("/", trim($dirPath, "/")); 331 | foreach ($pathParts as $part) { 332 | if ($part . "/" === $skipDir) { 333 | return false; 334 | } 335 | } 336 | } 337 | return true; 338 | } 339 | 340 | /** 341 | * Create documentation file for a given source file 342 | */ 343 | private function createDocumentationFile($sourcePath, $relPath, $sourceDir): bool 344 | { 345 | // Check cache first if enabled 346 | if ($this->useCache && !$this->forceRebuild) { 347 | $currentHash = $this->calculateFileHash($sourcePath); 348 | if ($currentHash !== false && isset($this->hashMap[$sourcePath]) && $this->hashMap[$sourcePath] === $currentHash) { 349 | echo "Skipping unchanged file: {$sourcePath}\n"; 350 | return false; // File was unchanged and skipped 351 | } 352 | } 353 | 354 | // Define output path - preserve complete directory structure including source directory name 355 | $outputDir = rtrim($this->outputDir, "/") . "/"; 356 | 357 | // Get just the source directory basename (without full path) 358 | $sourceDirName = basename(rtrim($sourceDir, "/")); 359 | 360 | // Prepend the source directory name to the relative path to maintain the full structure 361 | $fullRelPath = $sourceDirName . "/" . $relPath; 362 | $relDir = dirname($fullRelPath); 363 | $fileName = pathinfo($relPath, PATHINFO_FILENAME); 364 | 365 | // Create proper output path 366 | $outputPath = $outputDir . $relDir . "/" . $fileName . ".md"; 367 | 368 | // Ensure the directory exists 369 | $this->ensureDirectoryExists(dirname($outputPath)); 370 | 371 | // Check if file is valid for processing 372 | if (!$this->shouldProcessFile($sourcePath)) { 373 | return false; 374 | } 375 | 376 | // Read content 377 | $content = $this->readFileContent($sourcePath); 378 | 379 | // Generate documentation 380 | echo "Generating documentation for {$sourcePath}...\n"; 381 | $docContent = $this->generateDocumentation($sourcePath, $content); 382 | 383 | // Clean the documentation response 384 | $docContent = $this->cleanResponse($docContent); 385 | 386 | // Create the file title 387 | $title = basename($sourcePath); 388 | $fileContent = "# Documentation: " . $title . "\n\n"; 389 | $fileContent .= "Original file: `{$fullRelPath}`\n\n"; 390 | $fileContent .= $docContent; 391 | 392 | // Create documentation in file system 393 | if ($this->outputDir !== 'none') { 394 | $outputPath = $outputDir . $relDir . "/" . $fileName . ".md"; 395 | $this->ensureDirectoryExists(dirname($outputPath)); 396 | file_put_contents($outputPath, $fileContent); 397 | echo "Documentation created: {$outputPath}\n"; 398 | } 399 | 400 | // Create Jira documentation if enabled 401 | if ($this->jiraService) { 402 | $success = $this->jiraService->createOrUpdateDocumentation($title, $fileContent); 403 | if ($success) { 404 | echo "Documentation created in Jira: {$title}\n"; 405 | } else { 406 | echo "Failed to create documentation in Jira: {$title}\n"; 407 | } 408 | } 409 | 410 | // Create Confluence documentation if enabled 411 | if ($this->confluenceService) { 412 | $success = $this->confluenceService->createOrUpdatePage($title, $fileContent); 413 | if ($success) { 414 | echo "Documentation created in Confluence: {$title}\n"; 415 | } else { 416 | echo "Failed to create documentation in Confluence: {$title}\n"; 417 | } 418 | } 419 | 420 | // Update the hash map if caching is enabled 421 | if ($this->useCache) { 422 | $currentHash = $this->calculateFileHash($sourcePath); 423 | if ($currentHash !== false) { 424 | $this->hashMap[$sourcePath] = $currentHash; 425 | } 426 | } 427 | 428 | // Update the index after creating each documentation file 429 | if ($this->outputDir !== 'none') { 430 | $this->updateDocumentationIndex($outputPath, $outputDir); 431 | } 432 | 433 | // Rate limiting to avoid hitting API limits 434 | usleep(500000); // 0.5 seconds 435 | 436 | // Add the encountered file path to the encounteredFiles array 437 | $this->encounteredFiles[] = $sourcePath; 438 | 439 | return true; // File was processed 440 | } 441 | 442 | /** 443 | * Calculate the SHA1 hash of a file's content. 444 | * 445 | * @param string $filePath Path to the file. 446 | * @return string|false The SHA1 hash or false on failure. 447 | */ 448 | private function calculateFileHash(string $filePath): string|false 449 | { 450 | if (!file_exists($filePath)) { 451 | return false; 452 | } 453 | return sha1_file($filePath); 454 | } 455 | 456 | /** 457 | * Determine if file should be processed based on extension 458 | */ 459 | private function shouldProcessFile($filePath): bool 460 | { 461 | $ext = strtolower($this->getFileExtension($filePath)); 462 | $baseName = basename($filePath); 463 | 464 | // Skip hidden files 465 | if (strpos($baseName, ".") === 0) { 466 | return false; 467 | } 468 | 469 | // Only process files with allowed extensions 470 | return in_array($ext, $this->allowedExtensions); 471 | } 472 | 473 | /** 474 | * Get the file extension 475 | */ 476 | private function getFileExtension($filePath): string 477 | { 478 | return pathinfo($filePath, PATHINFO_EXTENSION); 479 | } 480 | 481 | /** 482 | * Read the content of a file safely 483 | */ 484 | private function readFileContent($filePath): string 485 | { 486 | try { 487 | return file_get_contents($filePath); 488 | } catch (Exception $e) { 489 | return "Error reading file: " . $e->getMessage(); 490 | } 491 | } 492 | 493 | /** 494 | * Generate documentation using the selected API provider 495 | */ 496 | private function generateDocumentation($filePath, $content): string 497 | { 498 | // Collect context about this file and its relationships before generating documentation 499 | $fileContext = $this->collectFileContext($filePath, $content); 500 | 501 | if ($this->apiProvider === "ollama") { 502 | return $this->generateDocumentationWithOllama($filePath, $content, $fileContext); 503 | } elseif ($this->apiProvider === "claude") { 504 | return $this->generateDocumentationWithClaude($filePath, $content, $fileContext); 505 | } elseif ($this->apiProvider === "gemini") { 506 | return $this->generateDocumentationWithGemini($filePath, $content, $fileContext); 507 | } elseif ($this->apiProvider === "azure") { 508 | return $this->generateDocumentationWithAzureOpenAI($filePath, $content, $fileContext); 509 | } else { 510 | return $this->generateDocumentationWithOpenAI($filePath, $content, $fileContext); 511 | } 512 | } 513 | 514 | /** 515 | * Collect context information about a file and its relationships 516 | * 517 | * @param string $filePath Path to the file 518 | * @param string $content Content of the file 519 | * @return array Context information 520 | */ 521 | private function collectFileContext(string $filePath, string $content): array 522 | { 523 | $fileExt = pathinfo($filePath, PATHINFO_EXTENSION); 524 | $context = [ 525 | 'imports' => [], 526 | 'relatedFiles' => [], 527 | 'routes' => [], 528 | 'controllers' => [], 529 | 'models' => [], 530 | ]; 531 | 532 | // Extract namespace and class name 533 | $namespace = $this->extractNamespace($content); 534 | $className = $this->extractClassName($content); 535 | $fullClassName = $namespace ? "$namespace\\$className" : $className; 536 | 537 | // Extract imports/use statements 538 | $imports = $this->extractImports($content); 539 | $context['imports'] = $imports; 540 | 541 | // Different analysis based on file type 542 | if ($fileExt === 'php') { 543 | // Check if this is a controller 544 | if (strpos($filePath, 'Controller') !== false || 545 | strpos($className, 'Controller') !== false) { 546 | $context['isController'] = true; 547 | $context['controllerActions'] = $this->extractControllerActions($content); 548 | $this->appContext['controllers'][$fullClassName] = [ 549 | 'path' => $filePath, 550 | 'actions' => $context['controllerActions'] 551 | ]; 552 | } 553 | 554 | // Check if this is a model 555 | if (strpos($filePath, 'Model') !== false || 556 | $this->isLikelyModel($content)) { 557 | $context['isModel'] = true; 558 | $context['modelRelationships'] = $this->extractModelRelationships($content); 559 | $this->appContext['models'][$fullClassName] = [ 560 | 'path' => $filePath, 561 | 'relationships' => $context['modelRelationships'] 562 | ]; 563 | } 564 | 565 | // Find related route definitions 566 | $context['routes'] = $this->findRelatedRoutes($className, $fullClassName); 567 | } // Check if it's a route file 568 | else if ($fileExt === 'php' && (strpos($filePath, 'routes') !== false || 569 | strpos($filePath, 'web.php') !== false || 570 | strpos($filePath, 'api.php') !== false)) { 571 | $context['isRouteFile'] = true; 572 | $routeData = $this->extractRoutes($content); 573 | $context['definedRoutes'] = $routeData; 574 | $this->appContext['routes'] = array_merge($this->appContext['routes'], $routeData); 575 | } 576 | 577 | // For all files, find related files based on imports 578 | foreach ($imports as $import) { 579 | // Convert import to possible file path 580 | $potentialFile = $this->findFileFromImport($import); 581 | if ($potentialFile) { 582 | $context['relatedFiles'][$import] = $potentialFile; 583 | } 584 | } 585 | 586 | return $context; 587 | } 588 | 589 | /** 590 | * Extract namespace from PHP content 591 | */ 592 | private function extractNamespace(string $content): string 593 | { 594 | if (preg_match('/namespace\s+([^;]+);/i', $content, $matches)) { 595 | return trim($matches[1]); 596 | } 597 | return ''; 598 | } 599 | 600 | /** 601 | * Extract class name from PHP content 602 | */ 603 | private function extractClassName(string $content): string 604 | { 605 | if (preg_match('/class\s+(\w+)(?:\s+extends|\s+implements|\s*\{)/i', $content, $matches)) { 606 | return trim($matches[1]); 607 | } 608 | return ''; 609 | } 610 | 611 | /** 612 | * Extract import/use statements from PHP content 613 | */ 614 | private function extractImports(string $content): array 615 | { 616 | $imports = []; 617 | if (preg_match_all('/use\s+([^;]+);/i', $content, $matches)) { 618 | foreach ($matches[1] as $import) { 619 | $imports[] = trim($import); 620 | } 621 | } 622 | return $imports; 623 | } 624 | 625 | /** 626 | * Extract controller action methods 627 | */ 628 | private function extractControllerActions(string $content): array 629 | { 630 | $actions = []; 631 | 632 | // Look for public methods that might be controller actions 633 | if (preg_match_all('/public\s+function\s+(\w+)\s*\([^)]*\)/i', $content, $matches)) { 634 | foreach ($matches[1] as $method) { 635 | // Skip common non-action methods 636 | if (in_array($method, ['__construct', '__destruct', 'middleware'])) { 637 | continue; 638 | } 639 | $actions[] = $method; 640 | } 641 | } 642 | 643 | return $actions; 644 | } 645 | 646 | /** 647 | * Check if a PHP file is likely a model 648 | */ 649 | private function isLikelyModel(string $content): bool 650 | { 651 | // Check for common model indicators 652 | $modelPatterns = [ 653 | '/extends\s+Model/i', 654 | '/class\s+\w+\s+extends\s+\w*Model\b/i', 655 | '/use\s+Illuminate\\\\Database\\\\Eloquent\\\\Model/i', 656 | '/\$table\s*=/i', 657 | '/\$fillable\s*=/i', 658 | '/\$guarded\s*=/i', 659 | '/hasMany|hasOne|belongsTo|belongsToMany/i' 660 | ]; 661 | 662 | foreach ($modelPatterns as $pattern) { 663 | if (preg_match($pattern, $content)) { 664 | return true; 665 | } 666 | } 667 | 668 | return false; 669 | } 670 | 671 | /** 672 | * Extract model relationships from content 673 | */ 674 | private function extractModelRelationships(string $content): array 675 | { 676 | $relationships = []; 677 | 678 | $relationshipTypes = ['hasMany', 'hasOne', 'belongsTo', 'belongsToMany', 679 | 'hasOneThrough', 'hasManyThrough', 'morphTo', 680 | 'morphOne', 'morphMany', 'morphToMany']; 681 | 682 | foreach ($relationshipTypes as $type) { 683 | if (preg_match_all('/function\s+(\w+)\s*\([^)]*\)[^{]*{[^}]*\$this->' . $type . '\s*\(\s*([^,\)]+)/i', 684 | $content, $matches, PREG_SET_ORDER)) { 685 | 686 | foreach ($matches as $match) { 687 | $methodName = trim($match[1]); 688 | $relatedModel = trim($match[2], "'\" \t\n\r\0\x0B"); 689 | 690 | $relationships[] = [ 691 | 'method' => $methodName, 692 | 'type' => $type, 693 | 'related' => $relatedModel 694 | ]; 695 | } 696 | } 697 | } 698 | 699 | return $relationships; 700 | } 701 | 702 | /** 703 | * Find routes related to a controller 704 | */ 705 | private function findRelatedRoutes(string $className, string $fullClassName): array 706 | { 707 | $relatedRoutes = []; 708 | 709 | foreach ($this->appContext['routes'] as $route) { 710 | if (isset($route['controller'])) { 711 | // Check against both short and full class names 712 | if ($route['controller'] === $className || 713 | $route['controller'] === $fullClassName) { 714 | $relatedRoutes[] = $route; 715 | } 716 | } 717 | } 718 | 719 | return $relatedRoutes; 720 | } 721 | 722 | /** 723 | * Extract routes from a routes file 724 | */ 725 | private function extractRoutes(string $content): array 726 | { 727 | $routes = []; 728 | 729 | // Match route definitions like Route::get('/path', 'Controller@method') 730 | $routePatterns = [ 731 | // Route::get('/path', 'Controller@method') 732 | '/Route::(get|post|put|patch|delete|options|any)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^@\'"]*)@([^\'"]*)[\'"]/', 733 | 734 | // Route::get('/path', [Controller::class, 'method']) 735 | '/Route::(get|post|put|patch|delete|options|any)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*\[\s*([^:,]+)::class\s*,\s*[\'"]([^\'"]+)[\'"]/', 736 | 737 | // Route names: ->name('route.name') 738 | '/->name\s*\(\s*[\'"]([^\'"]+)[\'"]/' 739 | ]; 740 | 741 | $currentRoute = null; 742 | 743 | // Split content by lines to process one at a time 744 | $lines = explode("\n", $content); 745 | foreach ($lines as $line) { 746 | // Check for HTTP method and path 747 | if (preg_match($routePatterns[0], $line, $matches)) { 748 | $currentRoute = [ 749 | 'method' => strtoupper($matches[1]), 750 | 'path' => $matches[2], 751 | 'controller' => $matches[3], 752 | 'action' => $matches[4], 753 | ]; 754 | $routes[] = $currentRoute; 755 | } // Check for array style controller 756 | else if (preg_match($routePatterns[1], $line, $matches)) { 757 | $currentRoute = [ 758 | 'method' => strtoupper($matches[1]), 759 | 'path' => $matches[2], 760 | 'controller' => $matches[3], 761 | 'action' => $matches[4], 762 | ]; 763 | $routes[] = $currentRoute; 764 | } // Check for route name 765 | else if (preg_match($routePatterns[2], $line, $matches) && $currentRoute) { 766 | $lastIndex = count($routes) - 1; 767 | if ($lastIndex >= 0) { 768 | $routes[$lastIndex]['name'] = $matches[1]; 769 | } 770 | } 771 | } 772 | 773 | return $routes; 774 | } 775 | 776 | /** 777 | * Try to find a file based on an import statement 778 | */ 779 | private function findFileFromImport(string $import): string 780 | { 781 | // Convert namespace to path (App\Http\Controllers\UserController -> app/Http/Controllers/UserController.php) 782 | $potentialPath = str_replace('\\', '/', $import) . '.php'; 783 | 784 | // Try common base directories 785 | $baseDirs = $this->sourceDirs; 786 | 787 | foreach ($baseDirs as $baseDir) { 788 | $fullPath = $baseDir . '/' . $potentialPath; 789 | if (file_exists($fullPath)) { 790 | return $fullPath; 791 | } 792 | 793 | // Try with lowercase first directory 794 | $parts = explode('/', $potentialPath); 795 | if (count($parts) > 0) { 796 | $parts[0] = strtolower($parts[0]); 797 | $altPath = implode('/', $parts); 798 | $fullPath = $baseDir . '/' . $altPath; 799 | if (file_exists($fullPath)) { 800 | return $fullPath; 801 | } 802 | } 803 | } 804 | 805 | return ''; 806 | } 807 | 808 | /** 809 | * Generate documentation using Ollama API 810 | */ 811 | private function generateDocumentationWithOllama($filePath, $content, $context = []): string 812 | { 813 | try { 814 | // Check content length and truncate if necessary 815 | if (strlen($content) > $this->maxTokens * 4) { 816 | // Rough estimate of token count 817 | $content = 818 | substr($content, 0, $this->maxTokens * 4) . 819 | "\n...(truncated for length)..."; 820 | } 821 | 822 | $prompt = $this->loadPromptTemplate($filePath, $content, $context); 823 | 824 | $postData = [ 825 | "model" => $this->model, 826 | "messages" => [ 827 | [ 828 | "role" => "system", 829 | "content" => 830 | "You are a technical documentation specialist with expertise in PHP applications.", 831 | ], 832 | ["role" => "user", "content" => $prompt], 833 | ], 834 | "max_tokens" => $this->maxTokens, 835 | "stream" => false, 836 | ]; 837 | 838 | // Ollama runs locally on the configured host and port 839 | $ch = curl_init( 840 | "http://{$this->ollamaHost}:{$this->ollamaPort}/api/chat" 841 | ); 842 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 843 | curl_setopt($ch, CURLOPT_POST, true); 844 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData)); 845 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 846 | "Content-Type: application/json", 847 | ]); 848 | 849 | $response = curl_exec($ch); 850 | if (curl_errno($ch)) { 851 | throw new Exception(curl_error($ch)); 852 | } 853 | curl_close($ch); 854 | 855 | $responseData = json_decode($response, true); 856 | 857 | if (isset($responseData["message"]["content"])) { 858 | return $responseData["message"]["content"]; 859 | } else { 860 | throw new Exception("Unexpected API response format"); 861 | } 862 | } catch (Exception $e) { 863 | return "# Documentation Generation Error\n\nThere was an error generating documentation for this file: " . 864 | $e->getMessage(); 865 | } 866 | } 867 | 868 | /** 869 | * Load and process prompt template with variables and context 870 | * 871 | * @param string $filePath Path to the file being documented 872 | * @param string $content Content of the file being documented 873 | * @param array $context Additional context information about the file 874 | * @return string Processed prompt with variables replaced 875 | */ 876 | private function loadPromptTemplate(string $filePath, string $content, array $context = []): string 877 | { 878 | try { 879 | // Default to built-in template if custom template doesn't exist 880 | $templatePath = $this->promptTemplate; 881 | if (!file_exists($templatePath)) { 882 | $templatePath = __DIR__ . "/../resources/templates/default-prompt.md"; 883 | } 884 | 885 | if (!file_exists($templatePath)) { 886 | throw new Exception("Prompt template not found: {$templatePath}"); 887 | } 888 | 889 | $template = file_get_contents($templatePath); 890 | 891 | // Format the context information as markdown 892 | $contextMd = $this->formatContextAsMarkdown($context); 893 | 894 | // Replace variables in the template 895 | $variables = [ 896 | '{FILE_PATH}' => $filePath, 897 | '{FILE_CONTENT}' => $content, 898 | '{FILE_NAME}' => basename($filePath), 899 | '{EXTENSION}' => pathinfo($filePath, PATHINFO_EXTENSION), 900 | '{BASE_NAME}' => pathinfo($filePath, PATHINFO_FILENAME), 901 | '{DIRECTORY}' => dirname($filePath), 902 | '{CONTEXT}' => $contextMd, 903 | '{TOC_LINK}' => $this->normalizeForToc(basename($filePath)), // Add normalized TOC link 904 | ]; 905 | 906 | return str_replace(array_keys($variables), array_values($variables), $template); 907 | 908 | } catch (Exception $e) { 909 | // If template loading fails, return a basic default prompt 910 | return "Please document the PHP file {$filePath}. Here's the content:\n\n```\n{$content}\n```"; 911 | } 912 | } 913 | 914 | /** 915 | * Format context information as markdown 916 | * 917 | * @param array $context Context information 918 | * @return string Formatted context as markdown 919 | */ 920 | private function formatContextAsMarkdown(array $context): string 921 | { 922 | $md = ""; 923 | 924 | if (!empty($context['imports'])) { 925 | $md .= "### Imports\n"; 926 | foreach ($context['imports'] as $import) { 927 | $md .= "- $import\n"; 928 | } 929 | $md .= "\n"; 930 | } 931 | 932 | if (!empty($context['relatedFiles'])) { 933 | $md .= "### Related Files\n"; 934 | foreach ($context['relatedFiles'] as $import => $file) { 935 | $md .= "- $import: $file\n"; 936 | } 937 | $md .= "\n"; 938 | } 939 | 940 | if (!empty($context['routes'])) { 941 | $md .= "### Related Routes\n"; 942 | foreach ($context['routes'] as $route) { 943 | $md .= "- {$route['method']} {$route['path']} -> {$route['controller']}@{$route['action']}\n"; 944 | } 945 | $md .= "\n"; 946 | } 947 | 948 | if (!empty($context['controllerActions'])) { 949 | $md .= "### Controller Actions\n"; 950 | foreach ($context['controllerActions'] as $action) { 951 | $md .= "- $action\n"; 952 | } 953 | $md .= "\n"; 954 | } 955 | 956 | if (!empty($context['modelRelationships'])) { 957 | $md .= "### Model Relationships\n"; 958 | foreach ($context['modelRelationships'] as $relationship) { 959 | $md .= "- {$relationship['method']} ({$relationship['type']}) -> {$relationship['related']}\n"; 960 | } 961 | $md .= "\n"; 962 | } 963 | 964 | return $md; 965 | } 966 | 967 | /** 968 | * Normalize a string for Table of Contents links 969 | */ 970 | private function normalizeForToc(string $text): string 971 | { 972 | return strtolower(preg_replace('/[^a-z0-9]+/', '-', trim($text))); 973 | } 974 | 975 | /** 976 | * Generate documentation using Claude API 977 | */ 978 | private function generateDocumentationWithClaude($filePath, $content, $context = []): string 979 | { 980 | return ""; 981 | try { 982 | // Check content length and truncate if necessary 983 | if (strlen($content) > $this->maxTokens * 4) { 984 | // Rough estimate of token count 985 | $content = 986 | substr($content, 0, $this->maxTokens * 4) . 987 | "\n...(truncated for length)..."; 988 | } 989 | 990 | $prompt = $this->loadPromptTemplate($filePath, $content, $context); 991 | 992 | $postData = [ 993 | "model" => $this->model, 994 | "system" => "You are an experienced technical documentation specialist with deep expertise in PHP applications, frameworks, and related technologies. Your task is to write clear, structured, and professional documentation that follows industry best practices. The document should be technically accurate, easy to understand for developers, and formatted with proper headings, examples, and explanations. Use a concise, authoritative, and professional tone throughout.", 995 | "messages" => [ 996 | ["role" => "user", "content" => $prompt], 997 | ], 998 | "max_tokens" => $this->maxTokens, 999 | "stream" => false, 1000 | ]; 1001 | 1002 | // Claude API endpoint 1003 | $ch = curl_init("https://api.anthropic.com/v1/messages"); 1004 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1005 | curl_setopt($ch, CURLOPT_POST, true); 1006 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData)); 1007 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 1008 | "Content-Type: application/json", 1009 | "anthropic-version: 2023-06-01", 1010 | "x-api-key: " . $this->openaiApiKey, 1011 | ]); 1012 | 1013 | $response = curl_exec($ch); 1014 | 1015 | if (curl_errno($ch)) { 1016 | throw new Exception(curl_error($ch)); 1017 | } 1018 | curl_close($ch); 1019 | 1020 | $responseData = json_decode($response, true); 1021 | 1022 | if (isset($responseData["content"][0]["text"])) { 1023 | return $responseData["content"][0]["text"]; 1024 | } else { 1025 | throw new Exception("Unexpected API response format: " . print_r($responseData, true)); 1026 | } 1027 | } catch (Exception $e) { 1028 | die ("# Documentation Generation Error\n\nThere was an error generating documentation for this file: " . $e->getMessage()); 1029 | } 1030 | } 1031 | 1032 | /** 1033 | * Generate documentation using Gemini API 1034 | */ 1035 | private function generateDocumentationWithGemini($filePath, $content, $context = []): string 1036 | { 1037 | try { 1038 | // Check content length and truncate if necessary 1039 | if (strlen($content) > $this->maxTokens * 4) { 1040 | // Rough estimate of token count 1041 | $content = 1042 | substr($content, 0, $this->maxTokens * 4) . 1043 | "\n...(truncated for length)..."; 1044 | } 1045 | 1046 | $prompt = $this->loadPromptTemplate($filePath, $content, $context); 1047 | 1048 | $postData = [ 1049 | "contents" => [ 1050 | [ 1051 | "role" => "user", 1052 | "parts" => [ 1053 | ["text" => $prompt] 1054 | ] 1055 | ] 1056 | ], 1057 | "generationConfig" => [ 1058 | "maxOutputTokens" => $this->maxTokens, 1059 | "temperature" => 0.2, 1060 | "topP" => 0.9 1061 | ] 1062 | ]; 1063 | 1064 | // Determine which Gemini model to use (gemini-1.5-pro by default if not specified) 1065 | $geminiModel = ($this->model === "gemini" || $this->model === "gemini-pro") ? "gemini-1.5-pro" : $this->model; 1066 | 1067 | $ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/{$geminiModel}:generateContent?key={$this->openaiApiKey}"); 1068 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1069 | curl_setopt($ch, CURLOPT_POST, true); 1070 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData)); 1071 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 1072 | "Content-Type: application/json" 1073 | ]); 1074 | 1075 | $response = curl_exec($ch); 1076 | if (curl_errno($ch)) { 1077 | throw new Exception(curl_error($ch)); 1078 | } 1079 | curl_close($ch); 1080 | 1081 | $responseData = json_decode($response, true); 1082 | 1083 | if (isset($responseData["candidates"][0]["content"]["parts"][0]["text"])) { 1084 | return $responseData["candidates"][0]["content"]["parts"][0]["text"]; 1085 | } else { 1086 | throw new Exception("Unexpected Gemini API response format: " . json_encode($responseData)); 1087 | } 1088 | } catch (Exception $e) { 1089 | return "# Documentation Generation Error\n\nThere was an error generating documentation for this file: " . 1090 | $e->getMessage(); 1091 | } 1092 | } 1093 | 1094 | /** 1095 | * Generate documentation using Azure OpenAI API 1096 | */ 1097 | private function generateDocumentationWithAzureOpenAI($filePath, $content, $context = []): string 1098 | { 1099 | try { 1100 | // Check content length and truncate if necessary 1101 | if (strlen($content) > $this->maxTokens * 4) { 1102 | // Rough estimate of token count 1103 | $content = 1104 | substr($content, 0, $this->maxTokens * 4) . 1105 | "\n...(truncated for length)..."; 1106 | } 1107 | 1108 | $prompt = $this->loadPromptTemplate($filePath, $content, $context); 1109 | 1110 | $postData = [ 1111 | "messages" => [ 1112 | [ 1113 | "role" => "system", 1114 | "content" => "You are a technical documentation specialist with expertise in PHP applications.", 1115 | ], 1116 | ["role" => "user", "content" => $prompt], 1117 | ], 1118 | "max_tokens" => 1500, 1119 | ]; 1120 | 1121 | // Azure OpenAI API requires a different endpoint format and authentication method 1122 | $endpoint = rtrim($this->azureEndpoint, '/'); 1123 | $url = "{$endpoint}/openai/deployments/{$this->azureDeployment}/chat/completions?api-version={$this->azureApiVersion}"; 1124 | 1125 | $ch = curl_init($url); 1126 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1127 | curl_setopt($ch, CURLOPT_POST, true); 1128 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData)); 1129 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 1130 | "Content-Type: application/json", 1131 | "api-key: " . $this->openaiApiKey, 1132 | ]); 1133 | 1134 | $response = curl_exec($ch); 1135 | if (curl_errno($ch)) { 1136 | throw new Exception(curl_error($ch)); 1137 | } 1138 | curl_close($ch); 1139 | 1140 | $responseData = json_decode($response, true); 1141 | 1142 | if (isset($responseData["choices"][0]["message"]["content"])) { 1143 | return $responseData["choices"][0]["message"]["content"]; 1144 | } else { 1145 | throw new Exception("Unexpected Azure OpenAI API response format: " . json_encode($responseData)); 1146 | } 1147 | } catch (Exception $e) { 1148 | return "# Documentation Generation Error\n\nThere was an error generating documentation for this file: " . 1149 | $e->getMessage(); 1150 | } 1151 | } 1152 | 1153 | /** 1154 | * Generate documentation using OpenAI API 1155 | */ 1156 | private function generateDocumentationWithOpenAI($filePath, $content, $context = []): string 1157 | { 1158 | try { 1159 | // Check content length and truncate if necessary 1160 | if (strlen($content) > $this->maxTokens * 4) { 1161 | // Rough estimate of token count 1162 | $content = 1163 | substr($content, 0, $this->maxTokens * 4) . 1164 | "\n...(truncated for length)..."; 1165 | } 1166 | 1167 | $prompt = $this->loadPromptTemplate($filePath, $content, $context); 1168 | 1169 | $postData = [ 1170 | "model" => $this->model, 1171 | "messages" => [ 1172 | [ 1173 | "role" => "system", 1174 | "content" => 1175 | "You are a technical documentation specialist with expertise in PHP applications.", 1176 | ], 1177 | ["role" => "user", "content" => $prompt], 1178 | ], 1179 | "max_tokens" => 1500, 1180 | ]; 1181 | 1182 | $ch = curl_init("https://api.openai.com/v1/chat/completions"); 1183 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1184 | curl_setopt($ch, CURLOPT_POST, true); 1185 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData)); 1186 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 1187 | "Content-Type: application/json", 1188 | "Authorization: Bearer " . $this->openaiApiKey, 1189 | ]); 1190 | 1191 | $response = curl_exec($ch); 1192 | if (curl_errno($ch)) { 1193 | throw new Exception(curl_error($ch)); 1194 | } 1195 | curl_close($ch); 1196 | 1197 | $responseData = json_decode($response, true); 1198 | 1199 | if (isset($responseData["choices"][0]["message"]["content"])) { 1200 | return $responseData["choices"][0]["message"]["content"]; 1201 | } else { 1202 | throw new Exception("Unexpected API response format"); 1203 | } 1204 | } catch (Exception $e) { 1205 | return "# Documentation Generation Error\n\nThere was an error generating documentation for this file: " . 1206 | $e->getMessage(); 1207 | } 1208 | } 1209 | 1210 | /** 1211 | * Remove tags from the response 1212 | */ 1213 | private function cleanResponse(string $response): string 1214 | { 1215 | return preg_replace('/.*?<\/think>/', '', $response); 1216 | } 1217 | 1218 | /** 1219 | * Update the documentation index file 1220 | * 1221 | * @param string $documentPath Path to the newly created document 1222 | * @param string $outputDir Base directory for documentation 1223 | */ 1224 | private function updateDocumentationIndex(string $documentPath, string $outputDir): void 1225 | { 1226 | $indexPath = $outputDir . "index.md"; 1227 | $relPath = substr($documentPath, strlen($outputDir)); 1228 | 1229 | // Replace backslashes with forward slashes for compatibility 1230 | $relPath = str_replace('\\', '/', $relPath); 1231 | 1232 | // Create a new index file if it doesn't exist 1233 | if (!file_exists($indexPath)) { 1234 | $indexContent = "# Documentation Index\n\n"; 1235 | $indexContent .= "This index is automatically generated and lists all documentation files:\n\n"; 1236 | file_put_contents($indexPath, $indexContent); 1237 | } 1238 | 1239 | // Get all documentation files 1240 | $allDocs = $this->getAllDocumentationFiles($outputDir); 1241 | 1242 | // Build index content 1243 | $indexContent = "# Documentation Index\n\n"; 1244 | $indexContent .= "This index is automatically generated and lists all documentation files:\n\n"; 1245 | 1246 | // Build a nested structure of directories and files 1247 | $tree = []; 1248 | foreach ($allDocs as $file) { 1249 | if (basename($file) === 'index.md') continue; // Skip index.md itself 1250 | 1251 | $relFilePath = substr($file, strlen($outputDir)); 1252 | $relFilePath = str_replace('\\', '/', $relFilePath); // Ensure forward slashes 1253 | $pathParts = explode('/', trim($relFilePath, '/')); 1254 | 1255 | // Add to tree structure 1256 | $this->addToTree($tree, $pathParts, $file, $outputDir); 1257 | } 1258 | 1259 | // Generate nested markdown from tree 1260 | $indexContent .= $this->generateNestedMarkdown($tree, $outputDir); 1261 | 1262 | file_put_contents($indexPath, $indexContent); 1263 | echo "Index updated: {$indexPath}\n"; 1264 | } 1265 | 1266 | /** 1267 | * Get all documentation files in the output directory 1268 | * 1269 | * @param string $outputDir The documentation output directory 1270 | * @return array List of markdown files 1271 | */ 1272 | private function getAllDocumentationFiles(string $outputDir): array 1273 | { 1274 | $files = []; 1275 | 1276 | if (!is_dir($outputDir)) { 1277 | return $files; 1278 | } 1279 | 1280 | $iterator = new RecursiveIteratorIterator( 1281 | new RecursiveDirectoryIterator( 1282 | $outputDir, 1283 | RecursiveDirectoryIterator::SKIP_DOTS 1284 | ) 1285 | ); 1286 | 1287 | foreach ($iterator as $file) { 1288 | if ($file->isFile() && $file->getExtension() === 'md') { 1289 | $files[] = $file->getPathname(); 1290 | } 1291 | } 1292 | 1293 | return $files; 1294 | } 1295 | 1296 | /** 1297 | * Add a file to the nested tree structure 1298 | * 1299 | * @param array &$tree Reference to the tree structure 1300 | * @param array $pathParts Path components 1301 | * @param string $file Full path to the file 1302 | * @param string $outputDir Output directory path 1303 | */ 1304 | private function addToTree(array &$tree, array $pathParts, string $file, string $outputDir): void 1305 | { 1306 | if (count($pathParts) === 1) { 1307 | // This is a file in the current level 1308 | $tree['_files'][] = [ 1309 | 'path' => $file, 1310 | 'name' => $pathParts[0], 1311 | 'title' => $this->getDocumentTitle($file), 1312 | 'relPath' => substr($file, strlen($outputDir)) 1313 | ]; 1314 | return; 1315 | } 1316 | 1317 | // This is a directory 1318 | $dirName = $pathParts[0]; 1319 | if (!isset($tree[$dirName])) { 1320 | $tree[$dirName] = []; 1321 | } 1322 | 1323 | // Process the rest of the path 1324 | array_shift($pathParts); 1325 | $this->addToTree($tree[$dirName], $pathParts, $file, $outputDir); 1326 | } 1327 | 1328 | /** 1329 | * Get the title of a markdown document 1330 | * 1331 | * @param string $filePath Path to the markdown file 1332 | * @return string The title or fallback to filename 1333 | */ 1334 | private function getDocumentTitle(string $filePath): string 1335 | { 1336 | if (!file_exists($filePath)) { 1337 | return basename($filePath); 1338 | } 1339 | 1340 | $content = file_get_contents($filePath); 1341 | // Try to find the first heading 1342 | if (preg_match('/^#\s+(.+)$/m', $content, $matches)) { 1343 | return trim($matches[1]); 1344 | } 1345 | 1346 | return pathinfo($filePath, PATHINFO_FILENAME); 1347 | } 1348 | 1349 | /** 1350 | * Generate nested markdown from the tree structure 1351 | * 1352 | * @param array $tree The tree structure 1353 | * @param string $outputDir Output directory path 1354 | * @param int $level Current nesting level (for indentation) 1355 | * @return string Markdown content 1356 | */ 1357 | private function generateNestedMarkdown(array $tree, string $outputDir, int $level = 0): string 1358 | { 1359 | $markdown = ''; 1360 | $indent = str_repeat(' ', $level); // 2 spaces per level for indentation 1361 | 1362 | // First output directories (sorted alphabetically) 1363 | $dirs = array_keys($tree); 1364 | sort($dirs); 1365 | 1366 | foreach ($dirs as $dir) { 1367 | if ($dir === '_files') continue; // Skip the files array, process it last 1368 | 1369 | $markdown .= "{$indent}* **{$dir}/**\n"; 1370 | $markdown .= $this->generateNestedMarkdown($tree[$dir], $outputDir, $level + 1); 1371 | } 1372 | 1373 | // Then output files in the current directory level 1374 | if (isset($tree['_files'])) { 1375 | // Sort files by name 1376 | usort($tree['_files'], function ($a, $b) { 1377 | return $a['name'] <=> $b['name']; 1378 | }); 1379 | 1380 | foreach ($tree['_files'] as $file) { 1381 | $title = $file['title']; 1382 | $relPath = $file['relPath']; 1383 | $markdown .= "{$indent}* [{$title}]({$relPath})\n"; 1384 | } 1385 | } 1386 | 1387 | return $markdown; 1388 | } 1389 | 1390 | /** 1391 | * Finalize the documentation index to ensure it's complete 1392 | */ 1393 | private function finalizeDocumentationIndex(): void 1394 | { 1395 | $outputDir = rtrim($this->outputDir, "/") . "/"; 1396 | $this->updateDocumentationIndex("", $outputDir); 1397 | echo "Documentation index finalized.\n"; 1398 | } 1399 | 1400 | /** 1401 | * Save the hash map to the cache file. 1402 | * 1403 | * @param array $map The hash map data to save. 1404 | */ 1405 | private function saveHashMap(array $map): void 1406 | { 1407 | if (!$this->useCache || !$this->cacheFilePath) { 1408 | return; 1409 | } 1410 | 1411 | try { 1412 | $this->ensureDirectoryExists(dirname($this->cacheFilePath)); 1413 | $content = json_encode($map, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 1414 | if ($content === false) { 1415 | throw new Exception("Failed to encode hash map to JSON."); 1416 | } 1417 | file_put_contents($this->cacheFilePath, $content); 1418 | } catch (Exception $e) { 1419 | echo "Warning: Could not save cache file: {$this->cacheFilePath} - {$e->getMessage()}\n"; 1420 | } 1421 | } 1422 | } 1423 | --------------------------------------------------------------------------------