Repository: PHP-CS-Fixer/PHP-CS-Fixer (php)

Issue:
Cache is not supported using the official docker image
## Bug report

<!--
  Before reporting a bug, make sure PHP CS Fixer is up-to-date.

  Check that the issue is NOT regarded to an applied rule by using the `-vvv` option (will list applied rules) on a minimal code snippet.
-->

### Description

The cache file is not generated using the docker official image.

According to the documentation:

> The cache is supported only when the tool was downloaded as a phar file or installed via Composer.

But I don't really see why the docker image should not support it as we can fetch it back thanks to volume binding.

Possible related: #6996.

### Runtime version

```
PHP CS Fixer 3.47.0 Big Changes by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.2.14
```

### Used command

```
❯ docker run --rm -t -v ${PWD}:${PWD} -w ${PWD} ghcr.io/php-cs-fixer/php-cs-fixer:3.47.0-php8.2 check --using-cache=yes                       
PHP CS Fixer 3.47.0 Big Changes by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.2.14
Loaded config default from "/home/sullivan/p/gitlab.com/ss-open/ci/recipes/.php-cs-fixer.dist.php".
 6/6 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

   1) rector.php
   2) .fixtures/fixture.php

Found 2 of 6 files that can be fixed in 0.038 seconds, 14.000 MB memory used

❯ ls .php-cs-fixer.cache                 
ls: cannot access '.php-cs-fixer.cache': No such file or directory
```

### Configuration file

```php
<?php

$finder = PhpCsFixer\Finder::create()
    ->in(__DIR__)
    ->ignoreDotFiles(false)
    ->ignoreVCS(true)
    ->ignoreVCSIgnored(true)
;

$config = new PhpCsFixer\Config();

return $config->setRules([
        '@PSR1' => true,
        '@PSR12' => true,
        'strict_param' => true,
        'array_syntax' => ['syntax' => 'short'],
    ])
    ->setRiskyAllowed(true)
    ->setFinder($finder)
;
```

### Code snippet that reproduces the problem

N/A

Pull request description:
fix: Allow using cache when running in Docker distribution
Fixes #7768.

-----

```shell
docker build --target dist -t fixer:local .
docker run --rm -it -v $(pwd):/code --entrypoint="" fixer:local sh

/code # php-cs-fixer check
PHP CS Fixer 3.48.1-DEV Small Changes by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.2
Loaded config default from "/code/.php-cs-fixer.php".
 1070/1070 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%


Found 0 of 1070 files that can be fixed in 40.647 seconds, 26.000 MB memory used

/code # php-cs-fixer check
PHP CS Fixer 3.48.1-DEV Small Changes by Fabien Potencier, Dariusz Ruminski and contributors.
PHP runtime: 8.3.2
Loaded config default from "/code/.php-cs-fixer.php".
Using cache file "/code/.php-cs-fixer.cache.json".
 1070/1070 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%


Found 0 of 1070 files that can be fixed in 0.136 seconds, 16.000 MB memory used
```

Second run inside Docker container uses cache.

Patch:
diff --git a/doc/usage.rst b/doc/usage.rst
index fa8baeac5..d3d6942e2 100644
--- a/doc/usage.rst
+++ b/doc/usage.rst
@@ -194,13 +194,11 @@ To visualize all the rules that belong to a ruleset:
 Caching
 -------
 
-The caching mechanism is enabled by default. This will speed up further runs by
-fixing only files that were modified since the last run. The tool will fix all
-files if the tool version has changed or the list of rules has changed.
-The cache is supported only when the tool was downloaded as a phar file or
-installed via Composer. The cache is written to the drive progressively, so do
-not be afraid of interruption - rerun the command and start where you left.
-The cache mechanism also supports executing the command in parallel.
+The caching mechanism is enabled by default. This will speed up further runs by fixing only files that were modified
+since the last run. The tool will fix all files if the tool version has changed or the list of rules has changed.
+The cache is supported only when the tool was downloaded as a PHAR file, executed within pre-built Docker image
+or installed via Composer. The cache is written to the drive progressively, so do not be afraid of interruption -
+rerun the command and start where you left. The cache mechanism also supports executing the command in parallel.
 
 Cache can be disabled via ``--using-cache`` option or config file:
 
diff --git a/src/Console/ConfigurationResolver.php b/src/Console/ConfigurationResolver.php
index fae06712b..5cb86a53d 100644
--- a/src/Console/ConfigurationResolver.php
+++ b/src/Console/ConfigurationResolver.php
@@ -479,7 +479,7 @@ public function getUsingCache(): bool
             }
         }
 
-        $this->usingCache = $this->usingCache && ($this->toolInfo->isInstalledAsPhar() || $this->toolInfo->isInstalledByComposer());
+        $this->usingCache = $this->usingCache && $this->isCachingAllowedForRuntime();
 
         return $this->usingCache;
     }
@@ -949,4 +949,11 @@ private static function separatedContextLessInclude(string $path): ConfigInterfa
 
         return $config;
     }
+
+    private function isCachingAllowedForRuntime(): bool
+    {
+        return $this->toolInfo->isInstalledAsPhar()
+            || $this->toolInfo->isInstalledByComposer()
+            || $this->toolInfo->isRunInsideDocker();
+    }
 }
diff --git a/src/ToolInfo.php b/src/ToolInfo.php
index cab29e451..fe5bdcea3 100644
--- a/src/ToolInfo.php
+++ b/src/ToolInfo.php
@@ -98,6 +98,15 @@ public function isInstalledByComposer(): bool
         return $this->isInstalledByComposer;
     }
 
+    /**
+     * Determines if the tool is run inside our pre-built Docker image.
+     * The `/fixer/` path comes from our Dockerfile, tool is installed there and added to global PATH via symlinked binary.
+     */
+    public function isRunInsideDocker(): bool
+    {
+        return is_file('/.dockerenv') && str_starts_with(__FILE__, '/fixer/');
+    }
+
     public function getPharDownloadUri(string $version): string
     {
         return sprintf(
diff --git a/src/ToolInfoInterface.php b/src/ToolInfoInterface.php
index 5124292b8..bc2a5e0b7 100644
--- a/src/ToolInfoInterface.php
+++ b/src/ToolInfoInterface.php
@@ -32,5 +32,7 @@ public function isInstalledAsPhar(): bool;
 
     public function isInstalledByComposer(): bool;
 
+    public function isRunInsideDocker(): bool;
+
     public function getPharDownloadUri(string $version): string;
 }


Test patch:
diff --git a/tests/Console/Command/SelfUpdateCommandTest.php b/tests/Console/Command/SelfUpdateCommandTest.php
index 5870dba82..42540356d 100644
--- a/tests/Console/Command/SelfUpdateCommandTest.php
+++ b/tests/Console/Command/SelfUpdateCommandTest.php
@@ -431,6 +431,11 @@ public function isInstalledByComposer(): bool
                 throw new \LogicException('Not implemented.');
             }
 
+            public function isRunInsideDocker(): bool
+            {
+                return false;
+            }
+
             public function getPharDownloadUri(string $version): string
             {
                 return sprintf('%s/%s.phar', $this->directory->url(), $version);
diff --git a/tests/Console/ConfigurationResolverTest.php b/tests/Console/ConfigurationResolverTest.php
index c0bd89ad6..138a91599 100644
--- a/tests/Console/ConfigurationResolverTest.php
+++ b/tests/Console/ConfigurationResolverTest.php
@@ -817,6 +817,84 @@ public function testResolveUsingCacheWithNegativeConfigAndNoOption(): void
         self::assertFalse($resolver->getUsingCache());
     }
 
+    /**
+     * @dataProvider provideResolveUsingCacheForRuntimesCases
+     */
+    public function testResolveUsingCacheForRuntimes(bool $cacheAllowed, bool $installedWithComposer, bool $asPhar, bool $inDocker): void
+    {
+        $config = new Config();
+        $config->setUsingCache(true);
+
+        $resolver = $this->createConfigurationResolver(
+            [],
+            $config,
+            '',
+            new class($installedWithComposer, $asPhar, $inDocker) implements ToolInfoInterface {
+                private bool $installedWithComposer;
+                private bool $asPhar;
+                private bool $inDocker;
+
+                public function __construct(bool $installedWithComposer, bool $asPhar, bool $inDocker)
+                {
+                    $this->installedWithComposer = $installedWithComposer;
+                    $this->asPhar = $asPhar;
+                    $this->inDocker = $inDocker;
+                }
+
+                public function getComposerInstallationDetails(): array
+                {
+                    throw new \BadMethodCallException();
+                }
+
+                public function getComposerVersion(): string
+                {
+                    throw new \BadMethodCallException();
+                }
+
+                public function getVersion(): string
+                {
+                    throw new \BadMethodCallException();
+                }
+
+                public function isInstalledAsPhar(): bool
+                {
+                    return $this->asPhar;
+                }
+
+                public function isInstalledByComposer(): bool
+                {
+                    return $this->installedWithComposer;
+                }
+
+                public function isRunInsideDocker(): bool
+                {
+                    return $this->inDocker;
+                }
+
+                public function getPharDownloadUri(string $version): string
+                {
+                    throw new \BadMethodCallException();
+                }
+            }
+        );
+
+        self::assertSame($cacheAllowed, $resolver->getUsingCache());
+    }
+
+    /**
+     * @return iterable<array{0: bool, 1: bool, 2: bool, 3: bool}>
+     */
+    public static function provideResolveUsingCacheForRuntimesCases(): iterable
+    {
+        yield 'none of the allowed runtimes' => [false, false, false, false];
+
+        yield 'composer installation' => [true, true, false, false];
+
+        yield 'PHAR distribution' => [true, false, true, false];
+
+        yield 'Docker runtime' => [true, false, false, true];
+    }
+
     public function testResolveCacheFileWithoutConfigAndOption(): void
     {
         $config = new Config();
@@ -1303,17 +1381,17 @@ private static function getFixtureDir(): string
     /**
      * @param array<string, mixed> $options
      */
-    private function createConfigurationResolver(array $options, Config $config = null, string $cwdPath = ''): ConfigurationResolver
-    {
-        if (null === $config) {
-            $config = new Config();
-        }
-
+    private function createConfigurationResolver(
+        array $options,
+        Config $config = null,
+        string $cwdPath = '',
+        ToolInfoInterface $toolInfo = null
+    ): ConfigurationResolver {
         return new ConfigurationResolver(
-            $config,
+            $config ?? new Config(),
             $options,
             $cwdPath,
-            $this->createToolInfoDouble()
+            $toolInfo ?? $this->createToolInfoDouble()
         );
     }
 
@@ -1379,6 +1457,11 @@ public function isInstalledByComposer(): bool
                 throw new \BadMethodCallException();
             }
 
+            public function isRunInsideDocker(): bool
+            {
+                return false;
+            }
+
             public function getPharDownloadUri(string $version): string
             {
                 throw new \BadMethodCallException();
diff --git a/tests/Console/WarningsDetectorTest.php b/tests/Console/WarningsDetectorTest.php
index 24dca2ab3..3e256782b 100644
--- a/tests/Console/WarningsDetectorTest.php
+++ b/tests/Console/WarningsDetectorTest.php
@@ -108,6 +108,11 @@ public function isInstalledByComposer(): bool
                 return $this->isInstalledByComposer;
             }
 
+            public function isRunInsideDocker(): bool
+            {
+                return false;
+            }
+
             public function getPharDownloadUri(string $version): string
             {
                 throw new \LogicException('Not implemented.');