1818
1919namespace FastForward \DevTools \Command ;
2020
21+ use FastForward \DevTools \PhpUnit \Coverage \CoverageSummaryLoader ;
22+ use FastForward \DevTools \PhpUnit \Coverage \CoverageSummaryLoaderInterface ;
23+ use InvalidArgumentException ;
24+ use RuntimeException ;
2125use Symfony \Component \Console \Input \InputArgument ;
2226use Symfony \Component \Console \Input \InputInterface ;
2327use Symfony \Component \Console \Input \InputOption ;
2428use Symfony \Component \Console \Output \OutputInterface ;
29+ use Symfony \Component \Filesystem \Filesystem ;
2530use Symfony \Component \Process \Process ;
2631
32+ use function is_numeric ;
33+
2734/**
2835 * Facilitates the execution of the PHPUnit testing framework.
2936 * This class MUST NOT be overridden and SHALL configure testing parameters dynamically.
@@ -35,6 +42,17 @@ final class TestsCommand extends AbstractCommand
3542 */
3643 public const string CONFIG = 'phpunit.xml ' ;
3744
45+ /**
46+ * @param Filesystem|null $filesystem the filesystem utility used for path resolution
47+ * @param CoverageSummaryLoaderInterface $coverageSummaryLoader the loader used for `coverage-php` summaries
48+ */
49+ public function __construct (
50+ ?Filesystem $ filesystem = null ,
51+ private readonly CoverageSummaryLoaderInterface $ coverageSummaryLoader = new CoverageSummaryLoader (),
52+ ) {
53+ parent ::__construct ($ filesystem );
54+ }
55+
3856 /**
3957 * Configures the testing command input constraints.
4058 *
@@ -90,6 +108,11 @@ protected function configure(): void
90108 shortcut: 'p ' ,
91109 mode: InputOption::VALUE_OPTIONAL ,
92110 description: 'Run tests in parallel using ParaTest. Optional number of workers. ' ,
111+ )
112+ ->addOption (
113+ name: 'min-coverage ' ,
114+ mode: InputOption::VALUE_REQUIRED ,
115+ description: 'Minimum line coverage percentage required for a successful run. ' ,
93116 );
94117 }
95118
@@ -106,6 +129,14 @@ protected function configure(): void
106129 */
107130 protected function execute (InputInterface $ input , OutputInterface $ output ): int
108131 {
132+ try {
133+ $ minimumCoverage = $ this ->resolveMinimumCoverage ($ input );
134+ } catch (InvalidArgumentException $ invalidArgumentException ) {
135+ $ output ->writeln ('<error> ' . $ invalidArgumentException ->getMessage () . '</error> ' );
136+
137+ return self ::FAILURE ;
138+ }
139+
109140 $ arguments = [
110141 '--configuration= ' . parent ::getConfigFile (self ::CONFIG ),
111142 '--bootstrap= ' . $ this ->resolvePath ($ input , 'bootstrap ' ),
@@ -115,19 +146,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
115146 $ arguments [] = '--cache-directory= ' . $ this ->resolvePath ($ input , 'cache-dir ' );
116147 }
117148
118- if (null !== $ input ->getOption ('coverage ' )) {
119- $ coveragePath = $ this ->resolvePath ($ input , 'coverage ' );
120-
121- foreach ($ this ->getPsr4Namespaces () as $ path ) {
122- $ arguments [] = '--coverage-filter= ' . $ this ->getAbsolutePath ($ path );
123- }
124-
125- $ arguments [] = '--coverage-text ' ;
126- $ arguments [] = '--coverage-html= ' . $ coveragePath ;
127- $ arguments [] = '--testdox-html= ' . $ coveragePath . '/testdox.html ' ;
128- $ arguments [] = '--coverage-clover= ' . $ coveragePath . '/clover.xml ' ;
129- $ arguments [] = '--coverage-php= ' . $ coveragePath . '/coverage.php ' ;
130- }
149+ $ coverageReportPath = $ this ->configureCoverageArguments ($ input , $ arguments , null !== $ minimumCoverage );
131150
132151 if (null !== $ input ->getOption ('filter ' )) {
133152 $ arguments [] = '--filter= ' . $ input ->getOption ('filter ' );
@@ -150,7 +169,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
150169
151170 $ command = new Process ([$ command , ...$ arguments , $ input ->getArgument ('path ' )]);
152171
153- return parent ::runProcess ($ command , $ output );
172+ $ result = parent ::runProcess ($ command , $ output );
173+
174+ if (self ::SUCCESS !== $ result || null === $ minimumCoverage || null === $ coverageReportPath ) {
175+ return $ result ;
176+ }
177+
178+ return $ this ->validateMinimumCoverage ($ coverageReportPath , $ minimumCoverage , $ output );
154179 }
155180
156181 /**
@@ -168,4 +193,109 @@ private function resolvePath(InputInterface $input, string $option): string
168193 {
169194 return $ this ->getAbsolutePath ($ input ->getOption ($ option ));
170195 }
196+
197+ /**
198+ * @param InputInterface $input the raw parameter definitions
199+ *
200+ * @return float|null the validated minimum coverage percentage, if configured
201+ */
202+ private function resolveMinimumCoverage (InputInterface $ input ): ?float
203+ {
204+ $ minimumCoverage = $ input ->getOption ('min-coverage ' );
205+
206+ if (null === $ minimumCoverage ) {
207+ return null ;
208+ }
209+
210+ if (! is_numeric ($ minimumCoverage )) {
211+ throw new InvalidArgumentException ('The --min-coverage option MUST be a numeric percentage. ' );
212+ }
213+
214+ $ minimumCoverage = (float ) $ minimumCoverage ;
215+
216+ if (0.0 > $ minimumCoverage || 100.0 < $ minimumCoverage ) {
217+ throw new InvalidArgumentException ('The --min-coverage option MUST be between 0 and 100. ' );
218+ }
219+
220+ return $ minimumCoverage ;
221+ }
222+
223+ /**
224+ * @param InputInterface $input the raw parameter definitions
225+ * @param array<int, string> $arguments the mutable argument list for the PHPUnit process
226+ * @param bool $requiresCoverageReport indicates whether a `coverage-php` report is required
227+ *
228+ * @return string|null the absolute path to the generated `coverage-php` report
229+ */
230+ private function configureCoverageArguments (
231+ InputInterface $ input ,
232+ array &$ arguments ,
233+ bool $ requiresCoverageReport ,
234+ ): ?string {
235+ $ coverageOption = $ input ->getOption ('coverage ' );
236+
237+ if (null === $ coverageOption && ! $ requiresCoverageReport ) {
238+ return null ;
239+ }
240+
241+ $ coveragePath = null !== $ coverageOption
242+ ? $ this ->resolvePath ($ input , 'coverage ' )
243+ : $ this ->resolvePath ($ input , 'cache-dir ' );
244+
245+ foreach ($ this ->getPsr4Namespaces () as $ path ) {
246+ $ arguments [] = '--coverage-filter= ' . $ this ->getAbsolutePath ($ path );
247+ }
248+
249+ if (null !== $ coverageOption ) {
250+ $ arguments [] = '--coverage-text ' ;
251+ $ arguments [] = '--coverage-html= ' . $ coveragePath ;
252+ $ arguments [] = '--testdox-html= ' . $ coveragePath . '/testdox.html ' ;
253+ $ arguments [] = '--coverage-clover= ' . $ coveragePath . '/clover.xml ' ;
254+ }
255+
256+ $ coverageReportPath = $ coveragePath . '/coverage.php ' ;
257+ $ arguments [] = '--coverage-php= ' . $ coverageReportPath ;
258+
259+ return $ coverageReportPath ;
260+ }
261+
262+ /**
263+ * @param string $coverageReportPath the generated `coverage-php` report path
264+ * @param float $minimumCoverage the required line coverage percentage
265+ * @param OutputInterface $output the output interface to log validation results
266+ *
267+ * @return int the final status code after validating minimum coverage
268+ */
269+ private function validateMinimumCoverage (
270+ string $ coverageReportPath ,
271+ float $ minimumCoverage ,
272+ OutputInterface $ output ,
273+ ): int {
274+ try {
275+ $ coverageSummary = $ this ->coverageSummaryLoader ->load ($ coverageReportPath );
276+ } catch (RuntimeException $ runtimeException ) {
277+ $ output ->writeln ('<error> ' . $ runtimeException ->getMessage () . '</error> ' );
278+
279+ return self ::FAILURE ;
280+ }
281+
282+ $ message = \sprintf (
283+ 'Minimum line coverage of %01.2F%% %s. Current coverage: %s (%d/%d lines). ' ,
284+ $ minimumCoverage ,
285+ $ coverageSummary ->percentage () >= $ minimumCoverage ? 'satisfied ' : 'was not met ' ,
286+ $ coverageSummary ->percentageAsString (),
287+ $ coverageSummary ->executedLines (),
288+ $ coverageSummary ->executableLines (),
289+ );
290+
291+ if ($ coverageSummary ->percentage () >= $ minimumCoverage ) {
292+ $ output ->writeln ('<info> ' . $ message . '</info> ' );
293+
294+ return self ::SUCCESS ;
295+ }
296+
297+ $ output ->writeln ('<error> ' . $ message . '</error> ' );
298+
299+ return self ::FAILURE ;
300+ }
171301}
0 commit comments