Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 177
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExportCodeCommand
0.00% covered (danger)
0.00%
0 / 177
0.00% covered (danger)
0.00%
0 / 7
1806
0.00% covered (danger)
0.00%
0 / 1
 getDirectoryTypeMappings
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 buildOptionParser
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 1
930
 writeMetadata
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 writeSectionHeader
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 writeSectionFooter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 writeFileContent
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare(strict_types=1);
3
4namespace App\Command;
5
6use Cake\Command\Command;
7use Cake\Console\Arguments;
8use Cake\Console\ConsoleIo;
9use Cake\Console\ConsoleOptionParser;
10use Cake\Core\Configure;
11use DirectoryIterator;
12use Exception;
13use RecursiveDirectoryIterator;
14use RecursiveIteratorIterator;
15use SplFileInfo;
16
17/**
18 * ExportCodeCommand
19 *
20 * This command exports custom code (app and plugins) with relative paths to a text file or separate files.
21 * This is useful if you want to have AI work with the source code.
22 */
23class ExportCodeCommand extends Command
24{
25    private const OUTPUT_FILENAME_BASE = 'willow_cms_code';
26    // Updated default extensions to include ctp for views by default easily
27    private const DEFAULT_EXTENSIONS = 'php,css,js,ctp';
28    // Added Webroot to default directories
29    private const DEFAULT_DIRECTORIES = 'Models,Controllers,Commands,Components,Views,Webroot';
30
31    /**
32     * Defines the standard mappings for application and plugin code directories.
33     * 'app' path is relative to ROOT.
34     * 'plugin' path is relative to the ROOT of a specific plugin (e.g., plugins/MyPlugin/).
35     *
36     * @return array<string, array<string, string>>
37     */
38    protected function getDirectoryTypeMappings(): array
39    {
40        return [
41            'Models' => ['app' => 'src' . DS . 'Model', 'plugin' => 'src' . DS . 'Model'],
42            'Views' => ['app' => 'templates', 'plugin' => 'templates'], // For .ctp or .php view files
43            'Controllers' => ['app' => 'src' . DS . 'Controller', 'plugin' => 'src' . DS . 'Controller'],
44            'Components' => ['app' => 'src' . DS . 'Controller' . DS . 'Component',
45            'plugin' => 'src' . DS . 'Controller' . DS . 'Component'],
46            'Commands' => ['app' => 'src' . DS . 'Command', 'plugin' => 'src' . DS . 'Command'],
47            'Jobs' => ['app' => 'src' . DS . 'Job', 'plugin' => 'src' . DS . 'Job'],
48            'Services' => ['app' => 'src' . DS . 'Service', 'plugin' => 'src' . DS . 'Service'],
49            'Utilities' => ['app' => 'src' . DS . 'Utility', 'plugin' => 'src' . DS . 'Utility'],
50            'Logs' => ['app' => 'src' . DS . 'Log', 'plugin' => 'src' . DS . 'Log'],
51            'Tests' => ['app' => 'tests', 'plugin' => 'tests'],
52            'Webroot' => ['app' => 'webroot', 'plugin' => 'webroot'], // For JS, CSS etc. in webroot
53            // Add other types like 'Config', etc. if needed
54        ];
55    }
56
57    /**
58     * Build the option parser for the command.
59     *
60     * @param \Cake\Console\ConsoleOptionParser $parser The option parser to be modified.
61     * @return \Cake\Console\ConsoleOptionParser The modified option parser.
62     */
63    protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
64    {
65        $dirMappings = $this->getDirectoryTypeMappings();
66        $availableDirKeys = implode(', ', array_keys($dirMappings));
67
68        $parser
69            ->setDescription('Exports custom app and plugin code with relative paths.')
70            ->addOption('separate', [
71                'help' => 'Create a separate file for each specified directory type.',
72                'boolean' => true,
73            ])
74            ->addOption('directories', [
75                'help' => 'Comma-separated list of directory types to export. Available: '
76                    . $availableDirKeys . '. Defaults to: ' . self::DEFAULT_DIRECTORIES,
77                'short' => 'd',
78                'default' => self::DEFAULT_DIRECTORIES,
79            ])
80            ->addOption('extensions', [
81                'help' => 'Comma-separated list of file extensions to include (e.g., php,ctp,js,css). Defaults to: '
82                    . self::DEFAULT_EXTENSIONS,
83                'short' => 'e',
84                'default' => self::DEFAULT_EXTENSIONS,
85            ]);
86
87        return $parser;
88    }
89
90    /**
91     * Execute the command.
92     *
93     * @param \Cake\Console\Arguments $args The command arguments.
94     * @param \Cake\Console\ConsoleIo $io The console io.
95     * @return int The exit code.
96     */
97    public function execute(Arguments $args, ConsoleIo $io): int
98    {
99        $rootDir = ROOT;
100        $outputBaseFilename = $rootDir . DS . self::OUTPUT_FILENAME_BASE;
101
102        $directoryTypeMappings = $this->getDirectoryTypeMappings();
103
104        $selectedDirKeysInput = $args->getOption('directories');
105        $selectedDirKeys = $selectedDirKeysInput ? array_map(
106            'trim',
107            explode(',', $selectedDirKeysInput),
108        ) : array_keys($directoryTypeMappings);
109
110        $allowedExtensionsInput = $args->getOption('extensions');
111        $allowedExtensions = array_map('trim', explode(',', strtolower($allowedExtensionsInput)));
112
113        $separateFiles = (bool)$args->getOption('separate');
114        $mainHandle = null;
115
116        if (!$separateFiles) {
117            $mainOutputFile = $outputBaseFilename . '.txt';
118            $mainHandle = fopen($mainOutputFile, 'w');
119            if (!$mainHandle) {
120                $io->error(sprintf('Failed to open main output file for writing: %s', $mainOutputFile));
121
122                return static::CODE_ERROR;
123            }
124            $this->writeMetadata($mainHandle);
125        }
126
127        foreach ($selectedDirKeys as $dirKey) {
128            if (!isset($directoryTypeMappings[$dirKey])) {
129                $io->warning(sprintf("Unknown directory type '%s' skipped.", $dirKey));
130                continue;
131            }
132
133            $mapping = $directoryTypeMappings[$dirKey];
134            $pathsToScan = [];
135
136            // 1. Add application path
137            $appPath = $rootDir . DS . $mapping['app'];
138            if (is_dir($appPath)) {
139                $pathsToScan[] = $appPath;
140            } else {
141                $io->verbose(sprintf("Application path for '%s' not found: %s", $dirKey, $appPath));
142            }
143
144            // 2. Add plugin paths
145            $pluginsRootDir = $rootDir . DS . 'plugins';
146            if (is_dir($pluginsRootDir) && !empty($mapping['plugin'])) { // Ensure plugin mapping exists
147                try {
148                    $pluginIterator = new DirectoryIterator($pluginsRootDir);
149                    foreach ($pluginIterator as $pluginDirInfo) {
150                        if ($pluginDirInfo->isDir() && !$pluginDirInfo->isDot()) {
151                            $pluginName = $pluginDirInfo->getFilename();
152                            $pluginSpecificPath = $pluginsRootDir . DS . $pluginName . DS . $mapping['plugin'];
153                            if (is_dir($pluginSpecificPath)) {
154                                $pathsToScan[] = $pluginSpecificPath;
155                            } else {
156                                 $io->verbose(sprintf(
157                                     "Plugin path for '%s' in plugin '%s' not found: %s",
158                                     $dirKey,
159                                     $pluginName,
160                                     $pluginSpecificPath,
161                                 ));
162                            }
163                        }
164                    }
165                } catch (Exception $e) {
166                    $io->warning(sprintf(
167                        "Could not iterate plugins directory '%s': %s",
168                        $pluginsRootDir,
169                        $e->getMessage(),
170                    ));
171                }
172            }
173
174            if (empty($pathsToScan)) {
175                $io->info(sprintf("No valid source directories found for type '%s'. Skipping.", $dirKey));
176                continue;
177            }
178
179            $currentHandle = $mainHandle;
180            if ($separateFiles) {
181                $separateOutputFile = $outputBaseFilename . '_' . str_replace(DS, '_', $dirKey) . '.txt';
182                $currentHandle = fopen($separateOutputFile, 'w');
183                if (!$currentHandle) {
184                    $io->error(sprintf('Failed to open separate output file for writing: %s', $separateOutputFile));
185                    continue;
186                }
187                $this->writeMetadata($currentHandle);
188            }
189
190            if (!$currentHandle) {
191                $io->error("No valid file handle for writing. This shouldn't happen.");
192
193                return static::CODE_ERROR;
194            }
195
196            $this->writeSectionHeader($currentHandle, $dirKey);
197            $filesExportedForThisKey = 0;
198
199            foreach ($pathsToScan as $scanPath) {
200                $io->verbose(sprintf("Scanning directory: %s for type '%s'", str_replace($rootDir .
201                DS, '', $scanPath), $dirKey));
202                try {
203                    $iterator = new RecursiveIteratorIterator(
204                        new RecursiveDirectoryIterator($scanPath, RecursiveDirectoryIterator::SKIP_DOTS |
205                        RecursiveDirectoryIterator::FOLLOW_SYMLINKS),
206                        RecursiveIteratorIterator::SELF_FIRST,
207                    );
208
209                    foreach ($iterator as $file) {
210                        /** @var \SplFileInfo $file */
211                        if ($file->isFile()) {
212                            $fileExtension = strtolower($file->getExtension());
213                            if (!in_array($fileExtension, $allowedExtensions, true)) {
214                                continue;
215                            }
216
217                           // Skip minified JS/CSS files (check basename without extension)
218                            if (
219                                in_array($fileExtension, ['js', 'css'], true) && preg_match(
220                                    '/\.min$/i',
221                                    $file->getBasename('.' . $fileExtension),
222                                )
223                            ) {
224                                $io->verbose(sprintf('Skipping minified file: %s', $file->getPathname()));
225                                continue;
226                            }
227
228                           // Skip vendor directories within webroot (e.g., webroot/vendor/some_lib)
229                           // or generally any path containing '/vendor/' if not desired
230                            if (strpos($file->getPathname(), DS . 'vendor' . DS) !== false) {
231                                $io->verbose(sprintf('Skipping vendor file: %s', $file->getPathname()));
232                                continue;
233                            }
234
235                            $this->writeFileContent($currentHandle, $file, $rootDir);
236                            $filesExportedForThisKey++;
237                        }
238                    }
239                } catch (Exception $e) {
240                    $io->warning(sprintf("Error scanning directory '%s': %s", $scanPath, $e->getMessage()));
241                }
242            }
243
244            if ($filesExportedForThisKey === 0) {
245                fwrite($currentHandle, "No files found matching criteria for this section.\n");
246            }
247
248            $this->writeSectionFooter($currentHandle, $dirKey);
249
250            if ($separateFiles) {
251                fclose($currentHandle);
252                $io->info(sprintf("Exported '%s' to separate file.", $dirKey));
253            }
254        }
255
256        if ($mainHandle) {
257            fclose($mainHandle);
258            $io->success(sprintf('Code exported successfully to %s', $outputBaseFilename . '.txt'));
259        } elseif ($separateFiles) {
260            $io->success('Code exported successfully to separate files in the project root, prefixed with ' .
261            self::OUTPUT_FILENAME_BASE . '_');
262        } else {
263            $io->warning('No code was exported. Check configurations or verbose output if no types were selected.');
264        }
265
266        return static::CODE_SUCCESS;
267    }
268
269    /**
270     * Write metadata to the output file.
271     *
272     * @param resource $handle The file handle to write to.
273     * @return void
274     */
275    protected function writeMetadata($handle): void
276    {
277        if (!$handle) {
278            return;
279        }
280        $metadata = [
281            'Export Date' => date('Y-m-d H:i:s'),
282            'CakePHP Version' => Configure::version(),
283            'PHP Version' => phpversion(),
284        ];
285
286        fwrite($handle, "METADATA:\n");
287        foreach ($metadata as $key => $value) {
288            fwrite($handle, "{$key}{$value}\n");
289        }
290        fwrite($handle, "\n" . str_repeat('=', 80) . "\n\n");
291    }
292
293    /**
294     * Write a section header to the output file.
295     *
296     * @param resource $handle The file handle to write to.
297     * @param string $sectionName The name of the section.
298     * @return void
299     */
300    protected function writeSectionHeader($handle, string $sectionName): void
301    {
302        if (!$handle) {
303            return;
304        }
305        fwrite($handle, "\n\n" . str_repeat('=', 80) . "\n");
306        fwrite($handle, "BEGIN SECTION: {$sectionName}\n");
307        fwrite($handle, str_repeat('=', 80) . "\n\n");
308    }
309
310    /**
311     * Write a section footer to the output file.
312     *
313     * @param resource $handle The file handle to write to.
314     * @param string $sectionName The name of the section.
315     * @return void
316     */
317    protected function writeSectionFooter($handle, string $sectionName): void
318    {
319        if (!$handle) {
320            return;
321        }
322        fwrite($handle, "\n\n" . str_repeat('=', 80) . "\n");
323        fwrite($handle, "END SECTION: {$sectionName}\n");
324        fwrite($handle, str_repeat('=', 80) . "\n\n");
325    }
326
327    /**
328     * Write the content of a file to the output file.
329     *
330     * @param resource $handle The file handle to write to.
331     * @param \SplFileInfo $file The file information.
332     * @param string $rootDir The root directory path.
333     * @return void
334     */
335    protected function writeFileContent($handle, SplFileInfo $file, string $rootDir): void
336    {
337        if (!$handle) {
338            return;
339        }
340        $relativePath = str_replace($rootDir . DS, '', $file->getPathname());
341        $content = file_get_contents($file->getPathname());
342        if ($content === false) {
343            $io = new ConsoleIo(); // Temporary IO for error, not ideal but better than nothing
344            $io->warning(sprintf('Could not read content of file: %s', $file->getPathname()));
345            $content = "[Error: Could not read file content for {$relativePath}]";
346        }
347
348        fwrite($handle, "FILE: {$relativePath}\n");
349        fwrite($handle, 'LAST MODIFIED: ' . date('Y-m-d H:i:s', (int)$file->getMTime()) . "\n");
350        fwrite($handle, 'SIZE: ' . $file->getSize() . " bytes\n");
351        fwrite($handle, "CONTENT:\n");
352        fwrite($handle, $content);
353        fwrite($handle, "\n\n// ----- END FILE: {$relativePath} -----\n\n");
354    }
355}