• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

valksor / php-dev-snapshot / 19705546221

26 Nov 2025 01:33PM UTC coverage: 49.15% (-12.4%) from 61.538%
19705546221

push

github

k0d3r1s
generic binary provider

376 of 765 relevant lines covered (49.15%)

2.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.82
/Service/SnapshotService.php
1
<?php declare(strict_types = 1);
2

3
/*
4
 * This file is part of the Valksor package.
5
 *
6
 * (c) Davis Zalitis (k0d3r1s)
7
 * (c) SIA Valksor <packages@valksor.com>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12

13
namespace ValksorDev\Snapshot\Service;
14

15
use Exception;
16
use FilesystemIterator;
17
use RecursiveDirectoryIterator;
18
use RecursiveIteratorIterator;
19
use Symfony\Component\Console\Style\SymfonyStyle;
20
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
21
use Valksor\Bundle\Service\PathFilter;
22
use Valksor\Bundle\Service\PathFilterHelper;
23
use ValksorDev\Snapshot\Util\ContentProcessor;
24
use ValksorDev\Snapshot\Util\OutputGenerator;
25

26
use function array_column;
27
use function array_slice;
28
use function array_sum;
29
use function basename;
30
use function count;
31
use function date;
32
use function dirname;
33
use function explode;
34
use function file_get_contents;
35
use function file_put_contents;
36
use function filesize;
37
use function getmypid;
38
use function implode;
39
use function is_array;
40
use function is_dir;
41
use function is_file;
42
use function mkdir;
43
use function pathinfo;
44
use function realpath;
45
use function round;
46
use function str_contains;
47
use function str_replace;
48
use function strlen;
49
use function strtolower;
50
use function substr_count;
51
use function unlink;
52

53
use const PATHINFO_EXTENSION;
54

55
/**
56
 * Service for generating MCP (Markdown Context Pack) snapshots of projects.
57
 *
58
 * This service provides intelligent file scanning and content analysis to create
59
 * AI-optimized project documentation. It combines advanced filtering capabilities
60
 * with binary detection and content limiting to produce focused, useful snapshots
61
 * for AI consumption.
62
 *
63
 * Key Features:
64
 * - Multi-path scanning with configurable limits
65
 * - Binary file detection and exclusion
66
 * - Gitignore integration for intelligent filtering
67
 * - Content size and line limiting
68
 * - MCP format output generation
69
 * - Comprehensive file type support
70
 *
71
 * Use Cases:
72
 * - AI code analysis and review
73
 * - Project documentation generation
74
 * - Code base summarization
75
 * - Knowledge base creation
76
 */
77
final class SnapshotService
78
{
79
    private PathFilter $fileFilter;
80
    private SymfonyStyle $io;
81
    private bool $isRunning = false;
82

83
    public function __construct(
84
        private readonly ParameterBagInterface $parameterBag,
85
    ) {
86
        $projectRoot = $parameterBag->get('kernel.project_dir');
11✔
87
        $this->fileFilter = PathFilter::createDefault($projectRoot);
11✔
88
    }
89

90
    public function isRunning(): bool
91
    {
92
        return $this->isRunning;
2✔
93
    }
94

95
    public function reload(): void
96
    {
97
        // Snapshot service doesn't support reloading as it's a one-time operation
98
    }
1✔
99

100
    public function removePidFile(
101
        string $pidFile,
102
    ): void {
103
        if (is_file($pidFile)) {
2✔
104
            unlink($pidFile);
1✔
105
        }
106
    }
107

108
    public function setIo(
109
        SymfonyStyle $io,
110
    ): void {
111
        $this->io = $io;
4✔
112
    }
113

114
    public function start(
115
        array $config,
116
    ): int {
117
        $this->isRunning = true;
4✔
118

119
        try {
120
            // Use multiple paths if provided, otherwise use default paths
121
            $projectRoot = $this->parameterBag->get('kernel.project_dir');
4✔
122
            $paths = $config['paths'] ?? $config['path'] ?? [$projectRoot];
4✔
123

124
            if (!is_array($paths)) {
4✔
125
                $paths = [$paths];
×
126
            }
127

128
            // Generate output filename if not provided
129
            $outputFile = $config['output_file'] ?? null;
4✔
130

131
            if (null === $outputFile) {
4✔
132
                $timestamp = date('Y_m_d_His');
×
133
                $outputFile = "snapshot_$timestamp.mcp";
×
134
            }
135

136
            // Ensure output directory exists
137
            $outputDir = dirname($outputFile);
4✔
138

139
            if (!is_dir($outputDir)) {
4✔
140
                mkdir($outputDir, 0o755, true);
×
141
            }
142

143
            // Get snapshot configuration from service-based structure (like hot reload)
144
            $snapshotConfig = $this->parameterBag->get('valksor.snapshot.options');
4✔
145

146
            // Create custom path filter with user exclusions (like hot reload)
147
            $excludePatterns = $snapshotConfig['exclude'] ?? [];
4✔
148
            $this->fileFilter = PathFilterHelper::createPathFilterWithExclusions($excludePatterns, $this->parameterBag->get('kernel.project_dir'));
4✔
149

150
            // Scan files from all paths
151
            $maxLines = $config['max_lines'] ?? 1000;
4✔
152
            $files = $this->scanMultiplePaths($paths, $maxLines, $config);
4✔
153

154
            if (empty($files)) {
4✔
155
                if (isset($this->io)) {
3✔
156
                    $this->io->warning('No files found to process.');
3✔
157
                }
158
                $this->isRunning = false;
3✔
159

160
                return 0;
3✔
161
            }
162

163
            // Generate output
164
            $projectName = basename($projectRoot);
1✔
165
            $stats = [
1✔
166
                'files_processed' => count($files),
1✔
167
                'total_size' => array_sum(array_column($files, 'size')),
1✔
168
            ];
1✔
169

170
            $output = OutputGenerator::generate($projectName, $files, $stats);
1✔
171

172
            // Write to file
173
            if (false === file_put_contents($outputFile, $output)) {
1✔
174
                if (isset($this->io)) {
×
175
                    $this->io->error("Failed to write output file: $outputFile");
×
176
                }
177
                $this->isRunning = false;
×
178

179
                return 1;
×
180
            }
181

182
            if (isset($this->io)) {
1✔
183
                $this->io->success("Snapshot generated: $outputFile");
1✔
184
                $this->io->table(
1✔
185
                    ['Metric', 'Value'],
1✔
186
                    [
1✔
187
                        ['Files processed', $stats['files_processed']],
1✔
188
                        ['Total size', round($stats['total_size'] / 1024, 2) . ' KB'],
1✔
189
                        ['Output file', $outputFile],
1✔
190
                    ],
1✔
191
                );
1✔
192
            }
193

194
            $this->isRunning = false;
1✔
195

196
            return 0;
1✔
197
        } catch (Exception $e) {
×
198
            if (isset($this->io)) {
×
199
                $this->io->error('Snapshot generation failed: ' . $e->getMessage());
×
200
            }
201
            $this->isRunning = false;
×
202

203
            return 1;
×
204
        }
205
    }
206

207
    public function stop(): void
208
    {
209
        $this->isRunning = false;
1✔
210
    }
211

212
    public function writePidFile(
213
        string $pidFile,
214
    ): void {
215
        $pid = getmypid();
1✔
216

217
        if (false !== $pid) {
1✔
218
            file_put_contents($pidFile, $pid);
1✔
219
        }
220
    }
221

222
    /**
223
     * Process a single file and return its data.
224
     *
225
     * This method handles content reading, binary detection, and line limiting
226
     * to ensure files are processed efficiently and safely for AI consumption.
227
     */
228
    private function processFile(
229
        string $path,
230
        string $relativePath,
231
        int $maxLines,
232
        array $config,
233
    ): ?array {
234
        try {
235
            $content = file_get_contents($path);
1✔
236

237
            if (false === $content) {
1✔
238
                return null;
×
239
            }
240

241
            // Check for binary content in case file filter missed it
242
            if (str_contains($content, "\x00")) {
1✔
243
                return null;
×
244
            }
245

246
            // Apply content processing if strip_comments is enabled
247
            $stripComments = $config['strip_comments'] ?? false;
1✔
248

249
            if ($stripComments) {
1✔
250
                $extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION));
1✔
251
                $content = ContentProcessor::processContent($content, $extension, true); // Preserve empty lines for structure
1✔
252
            }
253

254
            // Limit lines if specified
255
            if ($maxLines > 0) {
1✔
256
                $lines = explode("\n", $content);
1✔
257

258
                if (count($lines) > $maxLines) {
1✔
259
                    $content = implode("\n", array_slice($lines, 0, $maxLines));
×
260
                    $content .= "\n\n# [Truncated at $maxLines lines]";
×
261
                }
262
            }
263

264
            return [
1✔
265
                'path' => $path,
1✔
266
                'relative_path' => $relativePath,
1✔
267
                'content' => $content,
1✔
268
                'size' => strlen($content),
1✔
269
                'lines' => substr_count($content, "\n") + 1,
1✔
270
            ];
1✔
271
        } catch (Exception $e) {
×
272
            if (isset($this->io) && $this->io->isVerbose()) {
×
273
                $this->io->warning("Error processing file $path: " . $e->getMessage());
×
274
            }
275

276
            return null;
×
277
        }
278
    }
279

280
    /**
281
     * Scan files from a single path with recursive directory traversal.
282
     *
283
     * This method uses RecursiveDirectoryIterator for efficient file system
284
     * traversal and applies filtering rules to exclude unwanted files.
285
     */
286
    private function scanFiles(
287
        string $path,
288
        int $maxLines,
289
        array $config,
290
    ): array {
291
        $files = [];
4✔
292
        $processedCount = 0;
4✔
293
        $realPath = realpath($path);
4✔
294

295
        if (false === $realPath) {
4✔
296
            return $files;
×
297
        }
298

299
        // Use RecursiveDirectoryIterator for better performance
300
        $iterator = new RecursiveIteratorIterator(
4✔
301
            new RecursiveDirectoryIterator($realPath, FilesystemIterator::SKIP_DOTS),
4✔
302
            RecursiveIteratorIterator::SELF_FIRST,
4✔
303
        );
4✔
304

305
        foreach ($iterator as $fileInfo) {
4✔
306
            $path = $fileInfo->getPathname();
4✔
307

308
            // Calculate relative path from project root, not from scan path
309
            $projectRoot = $this->parameterBag->get('kernel.project_dir');
4✔
310
            $relativePath = str_replace($projectRoot . '/', '', $path);
4✔
311

312
            if ($fileInfo->isDir()) {
4✔
313
                // Use PathFilter like hot reload - directory filtering
314
                if ($this->fileFilter->shouldIgnoreDirectory($fileInfo->getBasename())) {
3✔
315
                    $iterator->next(); // Skip this directory
3✔
316
                }
317
            } else {
318
                // Use PathFilter like hot reload - file filtering
319
                if ($this->fileFilter->shouldIgnorePath($relativePath)) {
4✔
320
                    continue;
3✔
321
                }
322

323
                // Apply additional snapshot-specific limits not handled by PathFilter
324
                $maxFileSize = ($config['max_file_size'] ?? 1024) * 1024; // Convert KB to bytes
1✔
325

326
                if ($maxFileSize > 0) {
1✔
327
                    $size = filesize($path);
1✔
328

329
                    if (false !== $size && $size > $maxFileSize) {
1✔
330
                        continue;
×
331
                    }
332
                }
333

334
                // Check file limit BEFORE processing this file
335
                $maxFiles = $config['max_files'] ?? 500;
1✔
336

337
                if ($maxFiles > 0 && $processedCount >= $maxFiles) {
1✔
338
                    if (isset($this->io)) {
×
339
                        $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
×
340
                    }
341

342
                    break;
×
343
                }
344

345
                // Process file
346
                $fileData = $this->processFile($path, $relativePath, $maxLines, $config);
1✔
347

348
                if (null !== $fileData) {
1✔
349
                    $files[] = $fileData;
1✔
350
                    $processedCount++;
1✔
351
                }
352
            }
353
        }
354

355
        return $files;
4✔
356
    }
357

358
    /**
359
     * Scan files from multiple paths and merge results.
360
     *
361
     * This method handles path validation, file limit enforcement across
362
     * all paths, and merges results while maintaining processing statistics.
363
     */
364
    private function scanMultiplePaths(
365
        array $paths,
366
        int $maxLines,
367
        array $config,
368
    ): array {
369
        $allFiles = [];
4✔
370
        $processedCount = 0;
4✔
371

372
        // Get maxFiles from the dynamic config (from command line, not static config)
373
        $maxFiles = $config['max_files'] ?? 500;
4✔
374

375
        foreach ($paths as $path) {
4✔
376
            // Check file limit before scanning each path
377
            if ($maxFiles > 0 && $processedCount >= $maxFiles) {
4✔
378
                if (isset($this->io)) {
×
379
                    $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
×
380
                }
381

382
                break;
×
383
            }
384

385
            // Validate path
386
            if (!is_dir($path)) {
4✔
387
                if (isset($this->io)) {
×
388
                    $this->io->warning("Path does not exist or is not a directory: $path");
×
389
                }
390

391
                continue;
×
392
            }
393

394
            // Update path for validation and scanning
395
            // Merge files from this path
396
            foreach ($this->scanFiles($path, $maxLines, $config) as $file) {
4✔
397
                // Check global file limit
398
                if ($maxFiles > 0 && $processedCount >= $maxFiles) {
1✔
399
                    if (isset($this->io)) {
×
400
                        $this->io->warning("Maximum file limit ($maxFiles) reached. Processed $processedCount files.");
×
401
                    }
402

403
                    break;
×
404
                }
405

406
                $allFiles[] = $file;
1✔
407
                $processedCount++;
1✔
408
            }
409
        }
410

411
        return $allFiles;
4✔
412
    }
413
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc