├── 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 |
--------------------------------------------------------------------------------