Explorar o código

Allow config and temp directories to be configured independently. (#763)

ArclightHub hai 1 ano
pai
achega
7263152f8c

+ 5 - 4
camel/Camel.php

@@ -6,20 +6,21 @@ namespace Knuckles\Camel;
 use Illuminate\Support\Arr;
 use Illuminate\Support\Str;
 use Knuckles\Camel\Output\OutputEndpointData;
+use Knuckles\Scribe\Configuration\PathConfig;
 use Knuckles\Scribe\Tools\Utils;
 use Symfony\Component\Yaml\Yaml;
 
 
 class Camel
 {
-    public static function cacheDir(string $docsName = 'scribe'): string
+    public static function cacheDir(PathConfig $paths): string
     {
-        return ".$docsName/endpoints.cache";
+        return $paths->intermediateOutputPath('endpoints.cache');
     }
 
-    public static function camelDir(string $docsName = 'scribe'): string
+    public static function camelDir(PathConfig $paths): string
     {
-        return ".$docsName/endpoints";
+        return $paths->intermediateOutputPath('endpoints');
     }
 
     /**

+ 1 - 0
phpunit.xml

@@ -41,6 +41,7 @@
             <file>tests/Unit/ExtractorTest.php</file>
             <file>tests/Unit/ExtractorPluginSystemTest.php</file>
             <file>tests/Unit/ConfigDifferTest.php</file>
+            <file>tests/Unit/PathConfigurationTest.php</file>
         </testsuite>
         <testsuite name="Unit Tests 2">
             <file>tests/Unit/ExtractedEndpointDataTest.php</file>

+ 27 - 12
src/Commands/GenerateDocumentation.php

@@ -7,6 +7,7 @@ use Illuminate\Support\Arr;
 use Illuminate\Support\Facades\URL;
 use Illuminate\Support\Str;
 use Knuckles\Camel\Camel;
+use Knuckles\Scribe\Configuration\PathConfig;
 use Knuckles\Scribe\GroupedEndpoints\GroupedEndpointsFactory;
 use Knuckles\Scribe\Matching\RouteMatcherInterface;
 use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
@@ -23,7 +24,8 @@ class GenerateDocumentation extends Command
                             {--force : Discard any changes you've made to the YAML or Markdown files}
                             {--no-extraction : Skip extraction of route and API info and just transform the YAML and Markdown files into HTML}
                             {--no-upgrade-check : Skip checking for config file upgrades. Won't make things faster, but can be helpful if the command is buggy}
-                            {--config=scribe : choose which config file to use}
+                            {--config=scribe : Choose which config file to use}
+                            {--scribe-dir= : Specify the directory where Scribe stores its intermediate output and cache. Defaults to `.<config_file>`}
     ";
 
     protected $description = 'Generate API documentation from your Laravel/Dingo routes.';
@@ -34,7 +36,7 @@ class GenerateDocumentation extends Command
 
     protected bool $forcing;
 
-    protected string $configName;
+    protected PathConfig $paths;
 
     public function handle(RouteMatcherInterface $routeMatcher, GroupedEndpointsFactory $groupedEndpointsFactory): void
     {
@@ -47,9 +49,9 @@ class GenerateDocumentation extends Command
         }
 
         // Extraction stage - extract endpoint info either from app or existing Camel files (previously extracted data)
-        $groupedEndpointsInstance = $groupedEndpointsFactory->make($this, $routeMatcher, $this->configName);
+        $groupedEndpointsInstance = $groupedEndpointsFactory->make($this, $routeMatcher, $this->paths);
         $extractedEndpoints = $groupedEndpointsInstance->get();
-        $userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($this->configName));
+        $userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($this->paths));
         $groupedEndpoints = $this->mergeUserDefinedEndpoints($extractedEndpoints, $userDefinedEndpoints);
 
         // Output stage
@@ -61,7 +63,7 @@ class GenerateDocumentation extends Command
             $this->writeExampleCustomEndpoint();
         }
 
-        $writer = new Writer($this->docConfig, $this->configName);
+        $writer = new Writer($this->docConfig, $this->paths);
         $writer->writeDocs($groupedEndpoints);
 
         $this->upgradeConfigFileIfNeeded();
@@ -98,12 +100,19 @@ class GenerateDocumentation extends Command
 
         c::bootstrapOutput($this->output);
 
-        $this->configName = $this->option('config');
-        if (!config($this->configName)) {
-            throw new \InvalidArgumentException("The specified config (config/{$this->configName}.php) doesn't exist.");
+        $configName = $this->option('config');
+        if (!config($configName)) {
+            throw new \InvalidArgumentException("The specified config (config/{$configName}.php) doesn't exist.");
         }
 
-        $this->docConfig = new DocumentationConfig(config($this->configName));
+        $this->paths = new PathConfig(configName: $configName);
+        if ($this->hasOption('scribe-dir') && !empty($this->option('scribe-dir'))) {
+            $this->paths = new PathConfig(
+                configName: $configName, scribeDir: $this->option('scribe-dir')
+            );
+        }
+
+        $this->docConfig = new DocumentationConfig(config($this->paths->configName));
 
         // Force root URL so it works in Postman collection
         $baseUrl = $this->docConfig->get('base_url') ?? config('app.url');
@@ -146,7 +155,7 @@ class GenerateDocumentation extends Command
     protected function writeExampleCustomEndpoint(): void
     {
         // We add an example to guide users in case they need to add a custom endpoint.
-        copy(__DIR__ . '/../../resources/example_custom_endpoint.yaml', Camel::camelDir($this->configName) . '/custom.0.yaml');
+        copy(__DIR__ . '/../../resources/example_custom_endpoint.yaml', Camel::camelDir($this->paths) . '/custom.0.yaml');
     }
 
     protected function upgradeConfigFileIfNeeded(): void
@@ -155,12 +164,18 @@ class GenerateDocumentation extends Command
 
         $this->info("Checking for any pending upgrades to your config file...");
         try {
-            if (! $this->laravel['files']->exists($this->laravel->configPath("{$this->configName}.php"))) {
+            if (!$this->laravel['files']->exists(
+                $this->laravel->configPath($this->paths->configFileName())
+            )
+            ) {
                 $this->info("No config file to upgrade.");
                 return;
             }
 
-            $upgrader = Upgrader::ofConfigFile("config/{$this->configName}.php", __DIR__ . '/../../config/scribe.php')
+            $upgrader = Upgrader::ofConfigFile(
+                userOldConfigRelativePath: "config/{$this->paths->configFileName()}",
+                sampleNewConfigAbsolutePath: __DIR__ . '/../../config/scribe.php'
+            )
                 ->dontTouch(
                     'routes', 'example_languages', 'database_connections_to_transact', 'strategies', 'laravel.middleware',
                     'postman.overrides', 'openapi.overrides', 'groups', 'examples.models_source'

+ 4 - 2
src/Commands/Upgrade.php

@@ -4,6 +4,7 @@ namespace Knuckles\Scribe\Commands;
 
 use Illuminate\Console\Command;
 use Knuckles\Camel\Camel;
+use Knuckles\Scribe\Configuration\PathConfig;
 use Knuckles\Scribe\GroupedEndpoints\GroupedEndpointsFactory;
 use Knuckles\Scribe\Scribe;
 use Shalvah\Upgrader\Upgrader;
@@ -102,7 +103,8 @@ class Upgrade extends Command
         $this->info("We'll automatically import your current sorting into the config item `groups.order`.");
 
         $defaultGroup = config($this->configName.".default_group");
-        $extractedEndpoints = GroupedEndpointsFactory::fromCamelDir($this->configName)->get();
+        $pathConfig = new PathConfig($this->configName);
+        $extractedEndpoints = GroupedEndpointsFactory::fromCamelDir($pathConfig)->get();
 
         $order = array_map(function (array $group) {
             return array_map(function (array $endpoint) {
@@ -112,7 +114,7 @@ class Upgrade extends Command
         $groupsOrder = array_keys($order);
         $keyIndices = array_flip($groupsOrder);
 
-        $userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($this->configName));
+        $userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($pathConfig));
 
         if ($userDefinedEndpoints) {
             foreach ($userDefinedEndpoints as $endpoint) {

+ 47 - 0
src/Configuration/PathConfig.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Knuckles\Scribe\Configuration;
+
+/**
+ * A home for path configurations. The important paths Scribe depends on.
+ */
+class PathConfig
+{
+    public function __construct(
+        public string     $configName = 'scribe',
+        // FOr lack of a better name, we'll call this `scribeDir`.
+        // It's sort of the cache dir, where Scribe stores its intermediate outputs.
+        protected ?string $scribeDir = null
+    )
+    {
+        if (is_null($this->scribeDir)) {
+            $this->scribeDir = ".{$this->configName}";
+        }
+    }
+
+    public function outputPath(string $resolvePath = null, string $separator = '/'): string
+    {
+        if (is_null($resolvePath)) {
+            return $this->configName;
+        }
+
+        return "{$this->configName}{$separator}{$resolvePath}";
+    }
+
+    public function configFileName(): string
+    {
+        return "{$this->configName}.php";
+    }
+
+    /**
+     * The directory where Scribe writes its intermediate output (default is .<config> ie .scribe)
+     */
+    public function intermediateOutputPath(string $resolvePath = null, string $separator = '/'): string
+    {
+        if (is_null($resolvePath)) {
+            return $this->scribeDir;
+        }
+
+        return "{$this->scribeDir}{$separator}{$resolvePath}";
+    }
+}

+ 8 - 4
src/Extracting/ApiDetails.php

@@ -2,6 +2,7 @@
 
 namespace Knuckles\Scribe\Extracting;
 
+use Knuckles\Scribe\Configuration\PathConfig;
 use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
 use Knuckles\Scribe\Tools\Utils as u;
 use Knuckles\Scribe\Tools\DocumentationConfig;
@@ -23,11 +24,14 @@ class ApiDetails
 
     private array $lastKnownFileContentHashes = [];
 
-    public function __construct(DocumentationConfig $config = null, bool $preserveUserChanges = true, string $docsName = 'scribe')
-    {
-        $this->markdownOutputPath = ".{$docsName}"; //.scribe by default
+    public function __construct(
+        PathConfig          $paths,
+        DocumentationConfig $config = null,
+        bool                $preserveUserChanges = true
+    ) {
+        $this->markdownOutputPath = $paths->intermediateOutputPath(); //.scribe by default
         // If no config is injected, pull from global. Makes testing easier.
-        $this->config = $config ?: new DocumentationConfig(config($docsName));
+        $this->config = $config ?: new DocumentationConfig(config($paths->configName));
         $this->baseUrl = $this->config->get('base_url') ?? config('app.url');
         $this->preserveUserChanges = $preserveUserChanges;
 

+ 23 - 9
src/GroupedEndpoints/GroupedEndpointsFactory.php

@@ -3,34 +3,48 @@
 namespace Knuckles\Scribe\GroupedEndpoints;
 
 use Knuckles\Scribe\Commands\GenerateDocumentation;
+use Knuckles\Scribe\Configuration\PathConfig;
 use Knuckles\Scribe\Matching\RouteMatcherInterface;
 
 class GroupedEndpointsFactory
 {
-    public function make(GenerateDocumentation $command, RouteMatcherInterface $routeMatcher, string $docsName = 'scribe'): GroupedEndpointsContract
-    {
+    public function make(
+        GenerateDocumentation $command,
+        RouteMatcherInterface $routeMatcher,
+        PathConfig $paths
+    ): GroupedEndpointsContract {
         if ($command->isForcing()) {
-            return static::fromApp($command, $routeMatcher, false, $docsName);
+            return static::fromApp(
+                command: $command,
+                routeMatcher: $routeMatcher,
+                preserveUserChanges: false,
+                paths: $paths
+            );
         }
 
         if ($command->shouldExtract()) {
-            return static::fromApp($command, $routeMatcher, true, $docsName);
+            return static::fromApp(
+                command: $command,
+                routeMatcher: $routeMatcher,
+                preserveUserChanges: true,
+                paths: $paths
+            );
         }
 
-        return static::fromCamelDir($docsName);
+        return static::fromCamelDir($paths);
     }
 
     public static function fromApp(
         GenerateDocumentation $command,
         RouteMatcherInterface $routeMatcher,
         bool $preserveUserChanges,
-        string $docsName = 'scribe'
+        PathConfig $paths
     ): GroupedEndpointsFromApp {
-        return new GroupedEndpointsFromApp($command, $routeMatcher, $preserveUserChanges, $docsName);
+        return new GroupedEndpointsFromApp($command, $routeMatcher, $paths, $preserveUserChanges);
     }
 
-    public static function fromCamelDir(string $docsName = 'scribe'): GroupedEndpointsFromCamelDir
+    public static function fromCamelDir(PathConfig $paths): GroupedEndpointsFromCamelDir
     {
-        return new GroupedEndpointsFromCamelDir($docsName);
+        return new GroupedEndpointsFromCamelDir($paths);
     }
 }

+ 9 - 7
src/GroupedEndpoints/GroupedEndpointsFromApp.php

@@ -10,6 +10,7 @@ use Knuckles\Camel\Camel;
 use Knuckles\Camel\Extraction\ExtractedEndpointData;
 use Knuckles\Camel\Output\OutputEndpointData;
 use Knuckles\Scribe\Commands\GenerateDocumentation;
+use Knuckles\Scribe\Configuration\PathConfig;
 use Knuckles\Scribe\Exceptions\CouldntGetRouteDetails;
 use Knuckles\Scribe\Extracting\ApiDetails;
 use Knuckles\Scribe\Extracting\Extractor;
@@ -34,14 +35,15 @@ class GroupedEndpointsFromApp implements GroupedEndpointsContract
     public static string $cacheDir;
 
     public function __construct(
-        private GenerateDocumentation $command, private RouteMatcherInterface $routeMatcher,
-        private bool $preserveUserChanges = true, protected string $docsName = 'scribe'
-    )
-    {
+        private GenerateDocumentation $command,
+        private RouteMatcherInterface $routeMatcher,
+        protected PathConfig $paths,
+        private bool $preserveUserChanges = true
+    ) {
         $this->docConfig = $command->getDocConfig();
 
-        static::$camelDir = Camel::camelDir($this->docsName);
-        static::$cacheDir = Camel::cacheDir($this->docsName);
+        static::$camelDir = Camel::camelDir($this->paths);
+        static::$cacheDir = Camel::cacheDir($this->paths);
     }
 
     public function get(): array
@@ -282,7 +284,7 @@ class GroupedEndpointsFromApp implements GroupedEndpointsContract
 
     protected function makeApiDetails(): ApiDetails
     {
-        return new ApiDetails($this->docConfig, !$this->command->option('force'), $this->docsName);
+        return new ApiDetails($this->paths, $this->docConfig, !$this->command->option('force'));
     }
 
     /**

+ 5 - 6
src/GroupedEndpoints/GroupedEndpointsFromCamelDir.php

@@ -3,25 +3,24 @@
 namespace Knuckles\Scribe\GroupedEndpoints;
 
 use Knuckles\Camel\Camel;
+use Knuckles\Scribe\Configuration\PathConfig;
 
 class GroupedEndpointsFromCamelDir implements GroupedEndpointsContract
 {
-    protected string $docsName;
 
-    public function __construct(string $docsName = 'scribe')
+    public function __construct(protected PathConfig $paths)
     {
-        $this->docsName = $docsName;
     }
 
     public function get(): array
     {
-        if (!is_dir(Camel::camelDir($this->docsName))) {
+        if (!is_dir(Camel::camelDir($this->paths))) {
             throw new \InvalidArgumentException(
-                "Can't use --no-extraction because there are no endpoints in the " . Camel::camelDir($this->docsName) . " directory."
+                "Can't use --no-extraction because there are no endpoints in the " . Camel::camelDir($this->paths) . " directory."
             );
         }
 
-        return Camel::loadEndpointsIntoGroups(Camel::camelDir($this->docsName));
+        return Camel::loadEndpointsIntoGroups(Camel::camelDir($this->paths));
     }
 
     public function hasEncounteredErrors(): bool

+ 16 - 26
src/Writing/Writer.php

@@ -3,6 +3,7 @@
 namespace Knuckles\Scribe\Writing;
 
 use Illuminate\Support\Facades\Storage;
+use Knuckles\Scribe\Configuration\PathConfig;
 use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
 use Knuckles\Scribe\Tools\DocumentationConfig;
 use Knuckles\Scribe\Tools\Globals;
@@ -11,18 +12,8 @@ use Symfony\Component\Yaml\Yaml;
 
 class Writer
 {
-    /**
-     * The "name" of this docs instance. By default, it is "scribe".
-     * Used for multi-docs.
-     */
-    public string $docsName;
-
-    private DocumentationConfig $config;
-
     private bool $isStatic;
 
-    private string $markdownOutputPath;
-
     private ?string $staticTypeOutputPath;
 
     private ?string $laravelTypeOutputPath;
@@ -40,21 +31,15 @@ class Writer
 
     private string $laravelAssetsPath;
 
-    public function __construct(DocumentationConfig $config = null, $docsName = 'scribe')
+    public function __construct(protected DocumentationConfig $config, public PathConfig $paths)
     {
-        $this->docsName = $docsName;
-
-        // If no config is injected, pull from global, for easier testing.
-        $this->config = $config ?: new DocumentationConfig(config($docsName));
-
         $this->isStatic = $this->config->get('type') === 'static';
-        $this->markdownOutputPath = ".{$docsName}"; //.scribe by default
         $this->laravelTypeOutputPath = $this->getLaravelTypeOutputPath();
         $this->staticTypeOutputPath = rtrim($this->config->get('static.output_path', 'public/docs'), '/');
 
         $this->laravelAssetsPath = $this->config->get('laravel.assets_directory')
             ? '/' . $this->config->get('laravel.assets_directory')
-            : "/vendor/$this->docsName";
+            : "/vendor/" . $this->paths->outputPath();
     }
 
     /**
@@ -86,8 +71,9 @@ class Writer
                 $collectionPath = "{$this->staticTypeOutputPath}/collection.json";
                 file_put_contents($collectionPath, $collection);
             } else {
-                Storage::disk('local')->put("{$this->docsName}/collection.json", $collection);
-                $collectionPath = Storage::disk('local')->path("$this->docsName/collection.json");
+                $outputPath = $this->paths->outputPath('collection.json');
+                Storage::disk('local')->put($outputPath, $collection);
+                $collectionPath = Storage::disk('local')->path($outputPath);
             }
 
             c::success("Wrote Postman collection to: {$this->makePathFriendly($collectionPath)}");
@@ -105,8 +91,9 @@ class Writer
                 $specPath = "{$this->staticTypeOutputPath}/openapi.yaml";
                 file_put_contents($specPath, $spec);
             } else {
-                Storage::disk('local')->put("{$this->docsName}/openapi.yaml", $spec);
-                $specPath = Storage::disk('local')->path("$this->docsName/openapi.yaml");
+                $outputPath = $this->paths->outputPath('openapi.yaml');
+                Storage::disk('local')->put($outputPath, $spec);
+                $specPath = Storage::disk('local')->path($outputPath);
             }
 
             c::success("Wrote OpenAPI specification to: {$this->makePathFriendly($specPath)}");
@@ -180,8 +167,8 @@ class Writer
         // Rewrite asset links to go through Laravel
         $contents = preg_replace('#href="\.\./docs/css/(.+?)"#', 'href="{{ asset("' . $this->laravelAssetsPath . '/css/$1") }}"', $contents);
         $contents = preg_replace('#src="\.\./docs/(js|images)/(.+?)"#', 'src="{{ asset("' . $this->laravelAssetsPath . '/$1/$2") }}"', $contents);
-        $contents = str_replace('href="../docs/collection.json"', 'href="{{ route("' . $this->docsName . '.postman") }}"', $contents);
-        $contents = str_replace('href="../docs/openapi.yaml"', 'href="{{ route("' . $this->docsName . '.openapi") }}"', $contents);
+        $contents = str_replace('href="../docs/collection.json"', 'href="{{ route("' . $this->paths->outputPath('postman', '.') . '") }}"', $contents);
+        $contents = str_replace('href="../docs/openapi.yaml"', 'href="{{ route("' . $this->paths->outputPath('openapi', '.') . '") }}"', $contents);
 
         file_put_contents("$this->laravelTypeOutputPath/index.blade.php", $contents);
     }
@@ -193,7 +180,7 @@ class Writer
         // Then we convert them to HTML, and throw in the endpoints as well.
         /** @var HtmlWriter $writer */
         $writer = app()->makeWith(HtmlWriter::class, ['config' => $this->config]);
-        $writer->generate($groupedEndpoints, $this->markdownOutputPath, $this->staticTypeOutputPath);
+        $writer->generate($groupedEndpoints, $this->paths->intermediateOutputPath(), $this->staticTypeOutputPath);
 
         if (!$this->isStatic) {
             $this->performFinalTasksForLaravelType();
@@ -228,7 +215,10 @@ class Writer
     {
         if ($this->isStatic) return null;
 
-        return config('view.paths.0', function_exists('base_path') ? base_path("resources/views") : "resources/views") . "/$this->docsName";
+        return config(
+            'view.paths.0',
+            function_exists('base_path') ? base_path("resources/views") : "resources/views"
+        ). "/" . $this->paths->outputPath();
     }
 
     /**

+ 31 - 16
tests/GenerateDocumentation/OutputTest.php

@@ -139,39 +139,54 @@ class OutputTest extends BaseLaravelTest
 
     /** @test */
     public function supports_multi_docs_in_laravel_type_output()
+    {
+        $this->generate_with_paths(configName: "scribe_admin");
+    }
+
+    /** @test */
+    public function supports_custom_scribe_directory()
+    {
+        $this->generate_with_paths(configName: "scribe_admin", intermediateOutputDirectory: '5.5/Apple/26');
+    }
+
+    private function generate_with_paths($configName, $intermediateOutputDirectory = null)
     {
         RouteFacade::post('/api/withQueryParameters', [TestController::class, 'withQueryParameters']);
-        config(['scribe_admin' => config('scribe')]);
+        config([$configName => config('scribe')]);
         $title = "The Real Admin API";
-        config(['scribe_admin.title' => $title]);
-        config(['scribe_admin.type' => 'laravel']);
-        config(['scribe_admin.postman.enabled' => true]);
-        config(['scribe_admin.openapi.enabled' => true]);
-
-        $output = $this->generate(["--config" => "scribe_admin"]);
+        config(["{$configName}.title" => $title]);
+        config(["{$configName}.type" => 'laravel']);
+        config(["{$configName}.postman.enabled" => true]);
+        config(["{$configName}.openapi.enabled" => true]);
+
+        $pathOptions = ["--config" => $configName];
+        if ($intermediateOutputDirectory) {
+            $pathOptions["--scribe-dir"] = $intermediateOutputDirectory;
+        }
+        $output = $this->generate($pathOptions);
         $this->assertStringContainsString(
-            "Wrote Blade docs to: vendor/orchestra/testbench-core/laravel/resources/views/scribe_admin", $output
+            "Wrote Blade docs to: vendor/orchestra/testbench-core/laravel/resources/views/{$configName}", $output
         );
         $this->assertStringContainsString(
-            "Wrote Laravel assets to: vendor/orchestra/testbench-core/laravel/public/vendor/scribe_admin", $output
+            "Wrote Laravel assets to: vendor/orchestra/testbench-core/laravel/public/vendor/{$configName}", $output
         );
         $this->assertStringContainsString(
-            "Wrote Postman collection to: vendor/orchestra/testbench-core/laravel/storage/app/scribe_admin/collection.json", $output
+            "Wrote Postman collection to: vendor/orchestra/testbench-core/laravel/storage/app/{$configName}/collection.json", $output
         );
         $this->assertStringContainsString(
-            "Wrote OpenAPI specification to: vendor/orchestra/testbench-core/laravel/storage/app/scribe_admin/openapi.yaml", $output
+            "Wrote OpenAPI specification to: vendor/orchestra/testbench-core/laravel/storage/app/{$configName}/openapi.yaml", $output
         );
 
         $paths = collect([
-            Storage::disk('local')->path('scribe_admin/collection.json'),
-            Storage::disk('local')->path('scribe_admin/openapi.yaml'),
-            View::getFinder()->find('scribe_admin/index'),
+            Storage::disk('local')->path("{$configName}/collection.json"),
+            Storage::disk('local')->path("{$configName}/openapi.yaml"),
+            View::getFinder()->find("{$configName}/index"),
         ]);
         $paths->each(fn($path) => $this->assertFileContainsString($path, $title));
         $paths->each(fn($path) => unlink($path));
 
-        $this->assertDirectoryExists(".scribe_admin");
-        Utils::deleteDirectoryAndContents(".scribe_admin");
+        $this->assertDirectoryExists($intermediateOutputDirectory ?: ".{$configName}");
+        Utils::deleteDirectoryAndContents($intermediateOutputDirectory ?: ".{$configName}");
     }
 
     /** @test */

+ 39 - 0
tests/Unit/PathConfigurationTest.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Knuckles\Scribe\Tests\Unit;
+
+use Knuckles\Scribe\Configuration\PathConfig;
+use PHPUnit\Framework\TestCase;
+
+class PathConfigurationTest extends TestCase
+{
+    /** @test */
+    public function resolves_default_cache_path()
+    {
+        $pathConfig = new PathConfig('scribe');
+        $this->assertEquals('.scribe', $pathConfig->intermediateOutputPath());
+        $this->assertEquals('.scribe/endpoints', $pathConfig->intermediateOutputPath('endpoints'));
+        $this->assertEquals('scribe', $pathConfig->outputPath());
+        $this->assertEquals('scribe/tim', $pathConfig->outputPath('tim'));
+    }
+
+    /** @test */
+    public function resolves_cache_path_with_subdirectories()
+    {
+        $pathConfig = new PathConfig('scribe/bob');
+        $this->assertEquals('.scribe/bob', $pathConfig->intermediateOutputPath());
+        $this->assertEquals('.scribe/bob/tim', $pathConfig->intermediateOutputPath('tim'));
+        $this->assertEquals('scribe/bob', $pathConfig->outputPath());
+        $this->assertEquals('scribe/bob/tim', $pathConfig->outputPath('tim'));
+    }
+
+    /** @test */
+    public function supports_custom_cache_path()
+    {
+        $pathConfig = new PathConfig('scribe/bob', scribeDir: 'scribe_cache');
+        $this->assertEquals('scribe_cache', $pathConfig->intermediateOutputPath());
+        $this->assertEquals('scribe_cache/tim', $pathConfig->intermediateOutputPath('tim'));
+        $this->assertEquals('scribe/bob', $pathConfig->outputPath());
+        $this->assertEquals('scribe/bob/tim', $pathConfig->outputPath('tim'));
+    }
+}