diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b015552
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/.idea/
+/vendor/
+
+/.phpunit.result.cache
+/composer.lock
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..bb8f16c
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,41 @@
+{
+ "name": "dannecron/coverage-merger",
+ "description": "Merge coverage files into one",
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Dannecron"
+ }
+ ],
+ "require": {
+ "php": "^8.1.0",
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-simplexml": "*",
+ "adhocore/cli": "^1.6.1"
+ },
+ "require-dev": {
+ "mockery/mockery": "1.6.6",
+ "pestphp/pest": "2.18.2",
+ "squizlabs/php_codesniffer": "3.7.2"
+ },
+ "autoload": {
+ "psr-4": {
+ "Dannecron\\CoverageMerger\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests/"
+ }
+ },
+ "bin": "merger",
+ "minimum-stability": "stable",
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "pestphp/pest-plugin": false
+ }
+ }
+}
diff --git a/merger b/merger
new file mode 100755
index 0000000..b642ae1
--- /dev/null
+++ b/merger
@@ -0,0 +1,51 @@
+#!/usr/bin/env php
+onException(static function (\Throwable $exception, int $exitCode) use ($app): void {
+ $io = $app->io();
+
+ $io->error($exception->getMessage(), true);
+
+ if (($exception instanceof CommandException) === false) {
+ exit($exitCode);
+ }
+
+ if ($exception->isVerbose() === false) {
+ exit($exception->getCode());
+ }
+
+ $trace = $exception->getTrace();
+ $io->table(\array_map(static function (array $traceStep): array {
+ $methodOrFunc = \array_key_exists('class', $traceStep)
+ ? "{$traceStep['class']}::{$traceStep['function']}"
+ : $traceStep['function'];
+
+ return [
+ 'method/func' => $methodOrFunc,
+ 'file' => "{$traceStep['file']}:{$traceStep['line']}",
+
+ ];
+ }, $trace));
+
+ exit($exception->getCode());
+});
+
+$app->add(
+ new CloverMergeCommand(
+ new Clover\Handler(new Clover\Parser()),
+ new Clover\Renderer(),
+ $app,
+ ),
+);
+
+$app->handle($_SERVER['argv']);
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..9bbd771
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ ./vendor/*
+ ./tests/data/*
+ ./*\.(xml|js)$
+
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..d18bef3
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ ./tests
+
+
+
+
+ ./src
+
+
+
diff --git a/src/Clover/Dto/Accumulator.php b/src/Clover/Dto/Accumulator.php
new file mode 100644
index 0000000..ae1172b
--- /dev/null
+++ b/src/Clover/Dto/Accumulator.php
@@ -0,0 +1,41 @@
+ $files */
+ private array $files;
+
+ public function __construct()
+ {
+ $this->files = [];
+ }
+
+ /**
+ * @return array
+ */
+ public function getFiles(): array
+ {
+ return $this->files;
+ }
+
+ public function addFile(string $fileName, FileDto $file): self
+ {
+ /** @var FileDto|null $existedFile */
+ $existedFile = $this->getFiles()[$fileName] ?? null;
+
+ if ($existedFile !== null) {
+ $existedFile->mergeFile($file);
+
+ return $this;
+ }
+
+ $merged = \array_merge($this->files, [$fileName => $file]);
+ $this->files = $merged;
+
+ return $this;
+ }
+}
diff --git a/src/Clover/Dto/ClassDto.php b/src/Clover/Dto/ClassDto.php
new file mode 100644
index 0000000..3df1fa5
--- /dev/null
+++ b/src/Clover/Dto/ClassDto.php
@@ -0,0 +1,21 @@
+ $properties
+ */
+ public function __construct(
+ private readonly array $properties,
+ ) {
+ }
+
+ public function getProperties(): array
+ {
+ return $this->properties;
+ }
+}
diff --git a/src/Clover/Dto/FileDto.php b/src/Clover/Dto/FileDto.php
new file mode 100644
index 0000000..15e1283
--- /dev/null
+++ b/src/Clover/Dto/FileDto.php
@@ -0,0 +1,81 @@
+ */
+ private array $classes;
+ /** @var array */
+ private array $lines;
+
+ public function __construct(
+ public readonly ?string $packageName = null,
+ ) {
+ $this->classes = [];
+ $this->lines = [];
+ }
+
+ /**
+ * @return array
+ */
+ public function getClasses(): array
+ {
+ return $this->classes;
+ }
+
+ /**
+ * @return array
+ */
+ public function getLines(): array
+ {
+ return $this->lines;
+ }
+
+ public function hasClass(string $name): bool
+ {
+ return (bool) ($this->classes[$name] ?? false);
+ }
+
+ public function hasLine(int $number): bool
+ {
+ return (bool) ($this->lines[$number] ?? false);
+ }
+
+ public function mergeFile(FileDto $otherFile): self
+ {
+ $mergedClasses = \array_merge($otherFile->getClasses(), $this->classes);
+ $this->classes = $mergedClasses;
+
+ foreach ($otherFile->getLines() as $number => $line) {
+ $this->mergeLine($number, $line);
+ }
+
+ return $this;
+ }
+
+ public function mergeClass(string $name, ClassDto $class): self
+ {
+ if ($this->hasClass($name) === false) {
+ $this->classes[$name] = $class;
+ }
+
+ return $this;
+ }
+
+ public function mergeLine(int $number, LineDto $line): self
+ {
+ if ($this->hasLine($number) === false) {
+ $this->lines[$number] = $line;
+
+ return $this;
+ }
+
+ $existedLine = $this->lines[$number];
+ $existedLine->merge($line);
+
+ return $this;
+ }
+}
diff --git a/src/Clover/Dto/LineDto.php b/src/Clover/Dto/LineDto.php
new file mode 100644
index 0000000..8f8dea6
--- /dev/null
+++ b/src/Clover/Dto/LineDto.php
@@ -0,0 +1,44 @@
+ $properties Other properties on the line.
+ * E.g. name, visibility, complexity, crap
+ * @param int $count Number of hits on this line
+ */
+ public function __construct(
+ private array $properties,
+ private int $count,
+ ) {
+ }
+
+ public function getCount(): int
+ {
+ return $this->count;
+ }
+
+ public function getProperties(): array
+ {
+ return $this->properties;
+ }
+
+ public function getNum(): ?int
+ {
+ return isset($this->properties['num'])
+ ? (int) $this->properties['num']
+ : null;
+ }
+
+ public function merge(LineDto $otherLine): self
+ {
+ $this->properties = \array_merge($otherLine->getProperties(), $this->properties);
+ $this->count += $otherLine->getCount();
+
+ return $this;
+ }
+}
diff --git a/src/Clover/Dto/Metrics.php b/src/Clover/Dto/Metrics.php
new file mode 100644
index 0000000..4587568
--- /dev/null
+++ b/src/Clover/Dto/Metrics.php
@@ -0,0 +1,78 @@
+statementCount
+ + $this->conditionalCount
+ + $this->methodCount;
+ }
+
+ /**
+ * Return the number of covered elements.
+ * @return int
+ */
+ public function getCoveredElementCount(): int
+ {
+ return $this->coveredStatementCount
+ + $this->coveredConditionalCount
+ + $this->coveredMethodCount;
+ }
+
+ /**
+ * Merge another set of metrics into new one.
+ * @param Metrics $metrics
+ * @return Metrics
+ */
+ public function merge(Metrics $metrics): Metrics
+ {
+ $statementCount = $this->statementCount + $metrics->statementCount;
+ $coveredStatementCount = $this->coveredStatementCount + $metrics->coveredStatementCount;
+ $conditionalCount = $this->conditionalCount + $metrics->conditionalCount;
+ $coveredConditionalCount = $this->coveredConditionalCount + $metrics->coveredConditionalCount;
+ $methodCount = $this->methodCount + $metrics->methodCount;
+ $coveredMethodCount = $this->coveredMethodCount + $metrics->coveredMethodCount;
+ $classCount = $this->classCount + $metrics->classCount;
+ $fileCount = $this->fileCount + $metrics->fileCount;
+ $packageCount = $this->packageCount + $metrics->packageCount;
+
+ return new Metrics(
+ $statementCount,
+ $coveredStatementCount,
+ $conditionalCount,
+ $coveredConditionalCount,
+ $methodCount,
+ $coveredMethodCount,
+ $classCount,
+ $fileCount,
+ $packageCount,
+ );
+ }
+
+ public static function makeEmpty(): self
+ {
+ return new self(0, 0, 0, 0, 0, 0, 0);
+ }
+}
diff --git a/src/Clover/ElementsDictionary.php b/src/Clover/ElementsDictionary.php
new file mode 100644
index 0000000..fa6a441
--- /dev/null
+++ b/src/Clover/ElementsDictionary.php
@@ -0,0 +1,15 @@
+handleSingleDocument($document, $accumulator);
+ }
+
+ return $accumulator;
+ }
+
+ /**
+ * @param \SimpleXMLElement $document
+ * @param Accumulator|null $accumulator
+ * @return Accumulator
+ * @throws HandleException
+ */
+ public function handleSingleDocument(
+ \SimpleXMLElement $document,
+ ?Accumulator $accumulator = null
+ ): Accumulator {
+ if ($accumulator === null) {
+ $accumulator = new Accumulator();
+ }
+
+ $name = $document->getName();
+
+ if ($name !== self::ELEMENT_NAME_COVERAGE) {
+ throw new HandleException('Unexpected element: not coverage');
+ }
+
+ foreach ($document->children() as $project) {
+ $accumulator = $this->handleProject($project, $accumulator);
+ }
+
+ return $accumulator;
+ }
+
+ /**
+ * @param \SimpleXMLElement $project
+ * @param Accumulator $accumulator
+ * @return Accumulator
+ * @throws HandleException
+ */
+ private function handleProject(\SimpleXMLElement $project, Accumulator $accumulator): Accumulator
+ {
+ $name = $project->getName();
+ if ($name !== self::ELEMENT_NAME_PROJECT) {
+ throw new HandleException('Unexpected element: not project');
+ }
+
+ return $this->handleItems($project->children(), $accumulator);
+ }
+
+ /**
+ * @param \SimpleXMLElement $items
+ * @param Accumulator $accumulator
+ * @param string|null $packageName
+ * @return Accumulator
+ * @throws ParseException
+ */
+ private function handleItems(
+ \SimpleXMLElement $items,
+ Accumulator $accumulator,
+ ?string $packageName = null,
+ ): Accumulator {
+ foreach ($items as $item) {
+ $accumulator = $this->handleItem($item, $accumulator, $packageName);
+ }
+
+ return $accumulator;
+ }
+
+ /**
+ * @param \SimpleXMLElement $item
+ * @param Accumulator $accumulator
+ * @param string|null $packageName
+ * @return Accumulator
+ * @throws ParseException
+ */
+ private function handleItem(
+ \SimpleXMLElement $item,
+ Accumulator $accumulator,
+ ?string $packageName = null,
+ ): Accumulator {
+ $name = $item->getName();
+
+ if ($name === self::ELEMENT_NAME_PACKAGE) {
+ $attributes = $this->parser->getAttributes($item);
+ $attributePackageName = $attributes['name'] ?? null;
+
+ // Don't return here so that the package's files are still parsed regardless
+ if ($attributePackageName === null) {
+ return $accumulator;
+ }
+
+ return $this->handleItems($item->children(), $accumulator, (string) $attributePackageName);
+ }
+
+ if ($name === self::ELEMENT_NAME_FILE) {
+ return $this->handleFile($item, $accumulator, $packageName);
+ }
+
+ return $accumulator;
+ }
+
+ /**
+ * @param \SimpleXMLElement $xmlFile
+ * @param Accumulator $accumulator
+ * @param string|null $packageName
+ * @return Accumulator
+ * @throws ParseException
+ */
+ private function handleFile(
+ \SimpleXMLElement $xmlFile,
+ Accumulator $accumulator,
+ ?string $packageName = null,
+ ): Accumulator {
+ $attributes = $this->parser->getAttributes($xmlFile);
+ $fileName = $attributes['name'] ?? null;
+
+ if ($fileName === null) {
+ return $accumulator;
+ }
+
+ $file = $this->parser->parseFile($xmlFile, $packageName);
+
+ return $accumulator->addFile((string) $fileName, $file);
+ }
+}
diff --git a/src/Clover/Parser.php b/src/Clover/Parser.php
new file mode 100644
index 0000000..3aefdb2
--- /dev/null
+++ b/src/Clover/Parser.php
@@ -0,0 +1,89 @@
+children() as $child) {
+ $file = $this->parseChildXml($child, $file);
+ }
+
+ return $file;
+ }
+
+ public function parseClass(\SimpleXMLElement $xml): ClassDto
+ {
+ $properties = $this->getAttributes($xml);
+ $properties = \array_map(static fn (mixed $val): string => (string) $val, $properties);
+
+ return new ClassDto($properties);
+ }
+
+ /**
+ * @param \SimpleXMLElement $xml
+ * @return LineDto
+ * @throws ParseException
+ */
+ public function parseLine(\SimpleXMLElement $xml): LineDto
+ {
+ $properties = $this->getAttributes($xml);
+ $properties = \array_map(static fn (mixed $val): string => (string) $val, $properties);
+
+ $count = $properties['count'] ?? null;
+
+ if ($count === null) {
+ throw new ParseException('Unable to parse line, missing count attribute');
+ }
+
+ unset($properties['count']);
+
+ return new LineDto($properties, (int) $count);
+ }
+
+ public function getAttributes(\SimpleXMLElement $xml): array
+ {
+ return ((array) $xml->attributes())['@attributes'] ?? [];
+ }
+
+ /**
+ * @param \SimpleXMLElement $childXml
+ * @param FileDto $file
+ * @return FileDto
+ * @throws ParseException
+ */
+ private function parseChildXml(\SimpleXMLElement $childXml, FileDto $file): FileDto
+ {
+ $name = $childXml->getName();
+ $attributes = $this->getAttributes($childXml);
+
+ if ($name === self::ELEMENT_NAME_CLASS) {
+ $name = $attributes['name'] ?? '';
+ $file->mergeClass((string) $name, $this->parseClass($childXml));
+
+ return $file;
+ }
+
+ if ($name === self::ELEMENT_NAME_LINE) {
+ $file->mergeLine((int) $attributes['num'], $this->parseLine($childXml));
+ }
+
+ return $file;
+ }
+}
diff --git a/src/Clover/Renderer.php b/src/Clover/Renderer.php
new file mode 100644
index 0000000..5bc8cec
--- /dev/null
+++ b/src/Clover/Renderer.php
@@ -0,0 +1,239 @@
+getFiles();
+ \ksort($files);
+
+ $xmlDocument = new \DOMDocument('1.0', 'UTF-8');
+
+ $xmlCoverage = $xmlDocument->createElement('coverage');
+ $xmlCoverage->setAttribute('generated', (string)\time());
+ $xmlDocument->appendChild($xmlCoverage);
+
+ $xmlProject = $xmlDocument->createElement('project');
+ $xmlProject->setAttribute('timestamp', (string)\time());
+ $xmlCoverage->appendChild($xmlProject);
+
+ $projectMetrics = Metrics::makeEmpty();
+ $packages = [];
+
+ foreach ($files as $name => $file) {
+ [$xmlFile, $fileMetrics] = $this->renderFile($xmlDocument, $file, $name);
+ $projectMetrics = $projectMetrics->merge($fileMetrics);
+ $packageName = $file->packageName;
+ if ($packageName === null) {
+ $xmlProject->appendChild($xmlFile);
+ continue;
+ }
+
+ $existedPackage = $packages[$packageName] ?? null;
+ if ($existedPackage !== null) {
+ $existedPackage[0]->appendChild($xmlFile);
+ $existedPackage[1] = $existedPackage[1]->merge($fileMetrics);
+ $packages[$packageName] = $existedPackage;
+ continue;
+ }
+
+ $xmlPackage = $xmlDocument->createElement('package');
+ $xmlPackage->setAttribute('name', $packageName);
+ $xmlProject->appendChild($xmlPackage);
+ $xmlPackage->appendChild($xmlFile);
+
+ $packageMetrics = Metrics::makeEmpty();
+ $packageMetrics->packageCount = 1;
+ $packageMetrics = $packageMetrics->merge($fileMetrics);
+ $packages[$packageName] = [$xmlPackage, $packageMetrics];
+ }
+
+ foreach ($packages as $package) {
+ /** @var Metrics $packageMetrics */
+ $packageMetrics = $package[1];
+ $package[0]->appendChild($this->renderMetricsPackage($xmlDocument, $packageMetrics));
+ }
+
+ $xmlProject->appendChild($this->renderMetricsProject($xmlDocument, $projectMetrics));
+
+ return [$xmlDocument->saveXML(), $projectMetrics];
+ }
+
+ /**
+ * Create an XML element to represent these metrics under a file.
+ * @param \DOMDocument $xmlDocument The parent document
+ * @param Metrics $metrics
+ * @return \DOMElement
+ * @throws \DOMException
+ */
+ private function renderMetrics(\DOMDocument $xmlDocument, Metrics $metrics): \DOMElement
+ {
+ $xmlMetrics = $xmlDocument->createElement('metrics');
+ // We can't know the complexity, just set 0
+ // (attribute required by the clover xml schema)
+ $xmlMetrics->setAttribute('complexity', '0');
+
+ $xmlMetrics->setAttribute('elements', (string) $metrics->getElementCount());
+ $xmlMetrics->setAttribute('coveredelements', (string) $metrics->getCoveredElementCount());
+ $xmlMetrics->setAttribute('conditionals', (string) $metrics->conditionalCount);
+ $xmlMetrics->setAttribute('coveredconditionals', (string) $metrics->coveredConditionalCount);
+ $xmlMetrics->setAttribute('statements', (string) $metrics->statementCount);
+ $xmlMetrics->setAttribute('coveredstatements', (string) $metrics->coveredStatementCount);
+ $xmlMetrics->setAttribute('methods', (string) $metrics->methodCount);
+ $xmlMetrics->setAttribute('coveredmethods', (string) $metrics->coveredMethodCount);
+ $xmlMetrics->setAttribute('classes', (string) $metrics->classCount);
+
+ return $xmlMetrics;
+ }
+
+ /**
+ * Create an XML element to represent these metrics under a package.
+ * Contains all the attributes of the file context plus the number of files.
+ * @param \DOMDocument $xmlDocument The parent document.
+ * @param Metrics $metrics
+ * @return \DOMElement
+ * @throws \DOMException
+ */
+ private function renderMetricsPackage(\DOMDocument $xmlDocument, Metrics $metrics): \DOMElement
+ {
+ $xmlMetrics = $this->renderMetrics($xmlDocument, $metrics);
+ $xmlMetrics->setAttribute('files', (string) $metrics->fileCount);
+
+ return $xmlMetrics;
+ }
+
+ /**
+ * Create an XML element to represent these metrics under a project.
+ * Contains all the attributes of the package context plus the number of packages.
+ * @param \DOMDocument $xmlDocument The parent document.
+ * @param Metrics $metrics
+ * @return \DOMElement
+ * @throws \DOMException
+ */
+ private function renderMetricsProject(\DOMDocument $xmlDocument, Metrics $metrics): \DOMElement
+ {
+ $xmlMetrics = $this->renderMetricsPackage($xmlDocument, $metrics);
+ $xmlMetrics->setAttribute('packages', (string) $metrics->packageCount);
+
+ return $xmlMetrics;
+ }
+
+ /**
+ * @param \DOMDocument $document
+ * @param FileDto $fileDto
+ * @param string $name
+ * @return array{0:\DOMElement,1:Metrics}
+ * @throws \DOMException
+ */
+ private function renderFile(\DOMDocument $document, FileDto $fileDto, string $name): array
+ {
+ $xmlFile = $document->createElement('file');
+ $xmlFile->setAttribute('name', $name);
+
+ $classes = $fileDto->getClasses();
+ $lines = $fileDto->getLines();
+
+ // Metric counts
+ $statementCount = 0;
+ $coveredStatementCount = 0;
+ $conditionalCount = 0;
+ $coveredConditionalCount = 0;
+ $methodCount = 0;
+ $coveredMethodCount = 0;
+ $classCount = \count($classes);
+
+ foreach ($classes as $class) {
+ $xmlFile->appendChild($this->renderClass($document, $class));
+ }
+
+ foreach ($lines as $line) {
+ $xmlFile->appendChild($this->renderLine($document, $line));
+ $properties = $line->getProperties();
+
+ $covered = $line->getCount() > 0;
+ $type = $properties['type'] ?? 'stmt';
+
+ if ($type === 'method') {
+ $methodCount++;
+ if ($covered) {
+ $coveredMethodCount++;
+ }
+ } elseif ($type === 'stmt') {
+ $statementCount++;
+ if ($covered) {
+ $coveredStatementCount++;
+ }
+ } elseif ($type === 'cond') {
+ $conditionalCount++;
+ if ($covered) {
+ $coveredConditionalCount++;
+ }
+ }
+ }
+
+ $metrics = new Metrics(
+ $statementCount,
+ $coveredStatementCount,
+ $conditionalCount,
+ $coveredConditionalCount,
+ $methodCount,
+ $coveredMethodCount,
+ $classCount,
+ 1
+ );
+
+ $xmlFile->appendChild($this->renderMetrics($document, $metrics));
+
+ return [$xmlFile, $metrics];
+ }
+
+ /**
+ * @param \DOMDocument $document
+ * @param ClassDto $class
+ * @return \DOMElement
+ * @throws \DOMException
+ */
+ private function renderClass(\DOMDocument $document, ClassDto $class): \DOMElement
+ {
+ $xmlClass = $document->createElement('class');
+
+ foreach ($class->getProperties() as $key => $value) {
+ $xmlClass->setAttribute($key, $value);
+ }
+
+ return $xmlClass;
+ }
+
+ /**
+ * @param \DOMDocument $document
+ * @param LineDto $line
+ * @return \DOMElement
+ * @throws \DOMException
+ */
+ private function renderLine(\DOMDocument $document, LineDto $line): \DOMElement
+ {
+ $xmlLine = $document->createElement('line');
+ foreach ($line->getProperties() as $key => $value) {
+ $xmlLine->setAttribute($key, $value);
+ }
+
+ $xmlLine->setAttribute('count', (string) $line->getCount());
+
+ return $xmlLine;
+ }
+}
diff --git a/src/Command/CloverMergeCommand.php b/src/Command/CloverMergeCommand.php
new file mode 100644
index 0000000..943835c
--- /dev/null
+++ b/src/Command/CloverMergeCommand.php
@@ -0,0 +1,131 @@
+arguments('[files...]')
+ ->option('-w|--workdir', 'Path to workdir, to work with relative paths in files')
+ ->option('-o|--output', 'Path to result file', null, './merged.xml')
+ ->option('-s|--stats', 'Print calculated statistic', null, false);
+
+ $this->handler = $handler;
+ $this->renderer = $renderer;
+ }
+
+ /**
+ * @param Interactor $io
+ * @return void
+ * @throws InvalidArgumentException
+ */
+ public function interact(Interactor $io): void
+ {
+ $isVerbose = $this->verbosity >= 1;
+
+ $values = $this->values(false);
+ $files = $values['files'] ?? null;
+ $workdir = $values['workdir'] ?? null;
+
+ if ($files === null || \count($files) === 0) {
+ throw new InvalidArgumentException('files', $isVerbose);
+ }
+
+ if ($workdir !== null && \is_dir($workdir) === false) {
+ throw new InvalidArgumentException('workdir', $isVerbose);
+ }
+ }
+
+ /**
+ * @throws ExecuteException
+ */
+ public function execute(array $files, string $output, ?string $workdir, bool $stats): int
+ {
+ $isVerbose = $this->verbosity >= 1;
+
+ $fullOutputPath = $output;
+ if ($workdir !== null) {
+ $fullOutputPath = "{$workdir}/{$output}";
+ }
+
+ $documentsCollection = \array_map(static function (string $path) use ($workdir): \SimpleXMLElement {
+ $fullPath = $path;
+ if ($workdir !== null) {
+ $fullPath = "{$workdir}/{$path}";
+ }
+
+ $file = new \SplFileInfo($fullPath);
+
+ if ($file->isFile() === false || $file->isReadable() === false) {
+ throw new ExecuteException("File {$fullPath} does not exists or not readable");
+ }
+
+ $document = \simplexml_load_file(
+ $file->getPathname(),
+ \SimpleXMLElement::class,
+ LIBXML_NOWARNING | LIBXML_NOERROR,
+ );
+
+ if ($document === false) {
+ throw new ExecuteException("Unable to parse file {$file->getPathname()}");
+ }
+
+ return $document;
+ }, $files);
+
+ try {
+ $accumulator = $this->handler->handle(...$documentsCollection);
+ [$xml, $metrics] = $this->renderer->renderAccumulator($accumulator);
+ } catch (\Throwable $exception) {
+ throw new ExecuteException($exception->getMessage(), $isVerbose, $exception);
+ }
+
+ $writeResult = \file_put_contents($fullOutputPath, $xml);
+
+ if ($writeResult === false) {
+ throw new ExecuteException('Unable to write to given output file', $isVerbose);
+ }
+
+ if ($stats === false) {
+ return 0;
+ }
+
+ $filesDiscovered = $metrics->fileCount;
+ $elementCount = $metrics->getElementCount();
+ $coveredElementCount = $metrics->getCoveredElementCount();
+
+ if ($elementCount === 0) {
+ $coveragePercentage = 0;
+ } else {
+ $coveragePercentage = 100 * $coveredElementCount / $elementCount;
+ }
+
+ $io = $this->io();
+ $io->info(\sprintf("Files Discovered: %d", $filesDiscovered), true);
+ $io->info(
+ \sprintf("Final Coverage: %d/%d (%.2f%%)", $coveredElementCount, $elementCount, $coveragePercentage),
+ true,
+ );
+
+ return 0;
+ }
+}
diff --git a/src/Command/Exceptions/CommandException.php b/src/Command/Exceptions/CommandException.php
new file mode 100644
index 0000000..139781c
--- /dev/null
+++ b/src/Command/Exceptions/CommandException.php
@@ -0,0 +1,22 @@
+isVerbose = $isVerbose;
+ }
+
+ public function isVerbose(): bool
+ {
+ return $this->isVerbose;
+ }
+}
diff --git a/src/Command/Exceptions/ExecuteException.php b/src/Command/Exceptions/ExecuteException.php
new file mode 100644
index 0000000..6581f0f
--- /dev/null
+++ b/src/Command/Exceptions/ExecuteException.php
@@ -0,0 +1,15 @@
+