Skip to content
1 change: 1 addition & 0 deletions .cursor/commands/_analyze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
meticulously catalog and analyze all the changes
1 change: 1 addition & 0 deletions .cursor/commands/_in-branch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
in this branch, compared to the branch it's based on
1 change: 1 addition & 0 deletions .cursor/commands/_in-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
in this Git working tree, staged or unstaged
1 change: 1 addition & 0 deletions .cursor/commands/_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
provide a detailed report but don't make any changes yet.
1 change: 1 addition & 0 deletions .cursor/commands/_review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
review everything thoroughly with a focus on where these fall short of our development, architecture and testing rules
1 change: 0 additions & 1 deletion .cursor/commands/analyze.md

This file was deleted.

3 changes: 2 additions & 1 deletion .cursor/commands/create-branch.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Analyze the changes in this Git working tree and create a new branch with a suitable name.
Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged
and create a new branch with a suitable name.

Create the branch only (no commits yet). Use Conventional Commit types as branch prefixes:
feat/, fix/, docs/, style/, refactor/, perf/, test/, build/, ci/, chore/, revert/.
Expand Down
3 changes: 2 additions & 1 deletion .cursor/commands/create-commits.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Analyze the changes in this Git working tree and create one or more commits with suitable titles.
Meticulously catalog and analyze all the changes in this Git working tree, staged or unstaged
and create one or more commits with suitable titles.

Use Conventional Commits to group related changes into cohesive commits (commits should be independently meaningful).

Expand Down
1 change: 0 additions & 1 deletion .cursor/commands/review.md

This file was deleted.

2 changes: 1 addition & 1 deletion .cursor/rules/01-architecture.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ $service = new MyService(new Dependency());

**Rule:** ALL object creation must use `App::build()` except for value objects, DTOs, and pure data structures.

**Testing Exception:** Direct `Container` instantiation is acceptable in tests to isolate state and avoid shared singleton behavior. Production code must always use `App::build()`.
**Testing Exception:** Direct instantiation is acceptable in tests to make mocking and isolation simpler. Production code must always use `App::build()`.

```php
// ✅ ACCEPTABLE IN TESTS - Isolated container for test isolation
Expand Down
270 changes: 270 additions & 0 deletions app/Services/InventoryService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?php

declare(strict_types=1);

namespace Bigpixelrocket\DeployerPHP\Services;

use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;

/**
* Inventory file CRUD operations.
*
* @example
* $inventory = App::build(InventoryService::class);
*
* // Store values using dot notation
* $inventory->set('servers.production.host', 'example.com');
* $inventory->set('servers.production.user', 'deployer');
*
* // Or set entire object at once
* $inventory->set('servers.production', ['host' => 'example.com', 'user' => 'deployer']);
*
* // Retrieve values at any depth
* $inventory->get('servers.production.host'); // 'example.com'
* $inventory->get('servers.production'); // ['host' => 'example.com', 'user' => 'deployer']
* $inventory->get('servers'); // ['production' => ['host' => 'example.com', 'user' => 'deployer']]
* $inventory->get('servers.staging'); // null
*
* // Check if path exists
* if ($inventory->has('servers.production')) {
* // Path exists
* }
*
* // Delete path
* $inventory->delete('servers.production');
*/
class InventoryService
{
private readonly string $inventoryPath;
private readonly string $inventoryDir;

public function __construct(
private readonly Filesystem $filesystem,
) {
$this->inventoryPath = rtrim((string) getcwd(), '/').'/.deployer/inventory.yml';
$this->inventoryDir = dirname($this->inventoryPath);
}

//
// Public
// -------------------------------------------------------------------------------

/**
* Set a value using dot notation path.
*/
public function set(string $path, mixed $value): void
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);

$this->setByPath($inventory, $segments, $value);
$this->writeInventory($inventory);
}

/**
* Get a value using dot notation path.
*/
public function get(string $path): mixed
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);

return $this->getByPath($inventory, $segments);
}

/**
* Get the entire inventory structure.
*
* @return array<string, mixed>
*/
public function getAll(): array
{
return $this->readInventory();
}

/**
* Check if a path exists using dot notation.
*/
public function has(string $path): bool
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);

return $this->hasByPath($inventory, $segments);
}

/**
* Delete a value using dot notation path.
*/
public function delete(string $path): void
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);

$this->unsetByPath($inventory, $segments);
$this->writeInventory($inventory);
}
Comment on lines +53 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Docblocks need parameter/return tags per repo rules.

Per the repository’s PHP guidelines, every method docblock must include the parameter and return annotations. All of the public methods here are missing those tags. Please add the required annotations across the class.

As per coding guidelines

Here’s an example of the expected format:

     /**
      * Set a value using dot notation path.
+     *
+     * @param string $path
+     * @param mixed  $value
+     *
+     * @return void
      */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Set a value using dot notation path.
*/
public function set(string $path, mixed $value): void
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
$this->setByPath($inventory, $segments, $value);
$this->writeInventory($inventory);
}
/**
* Get a value using dot notation path.
*/
public function get(string $path): mixed
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
return $this->getByPath($inventory, $segments);
}
/**
* Get the entire inventory structure.
*
* @return array<string, mixed>
*/
public function getAll(): array
{
return $this->readInventory();
}
/**
* Check if a path exists using dot notation.
*/
public function has(string $path): bool
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
return $this->hasByPath($inventory, $segments);
}
/**
* Delete a value using dot notation path.
*/
public function delete(string $path): void
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
$this->unsetByPath($inventory, $segments);
$this->writeInventory($inventory);
}
/**
* Set a value using dot notation path.
*
* @param string $path
* @param mixed $value
*
* @return void
*/
public function set(string $path, mixed $value): void
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
$this->setByPath($inventory, $segments, $value);
$this->writeInventory($inventory);
}
/**
* Get a value using dot notation path.
*/
public function get(string $path): mixed
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
return $this->getByPath($inventory, $segments);
}
/**
* Get the entire inventory structure.
*
* @return array<string, mixed>
*/
public function getAll(): array
{
return $this->readInventory();
}
/**
* Check if a path exists using dot notation.
*/
public function has(string $path): bool
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
return $this->hasByPath($inventory, $segments);
}
/**
* Delete a value using dot notation path.
*/
public function delete(string $path): void
{
$inventory = $this->readInventory();
$segments = $this->parsePath($path);
$this->unsetByPath($inventory, $segments);
$this->writeInventory($inventory);
}
🤖 Prompt for AI Agents
In app/Services/InventoryService.php around lines 53 to 107, the public method
docblocks for set, get, getAll, has, and delete are missing @param/@return
annotations; update each docblock to include the parameter and return tags per
repo rules (set: @param string $path, @param mixed $value, @return void; get:
@param string $path, @return mixed; getAll: @return array<string,mixed>; has:
@param string $path, @return bool; delete: @param string $path, @return void),
keeping descriptions concise and matching existing docblock style.


//
// Private
// -------------------------------------------------------------------------------

//
// Dot Notation Helpers

/**
* Parse dot notation path into array segments.
*
* @return array<int, string>
*/
private function parsePath(string $path): array
{
return explode('.', $path);
}

/**
* Get value from nested array using dot notation path segments.
*
* @param array<string, mixed> $data
* @param array<int, string> $segments
*/
private function getByPath(array $data, array $segments): mixed
{
$current = $data;

foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
}

return $current;
}

/**
* Set value in nested array using dot notation path segments.
*
* @param array<string, mixed> $data
* @param array<int, string> $segments
*/
private function setByPath(array &$data, array $segments, mixed $value): void
{
$current = &$data;

foreach ($segments as $segment) {
if (!is_array($current)) {
$current = [];
}

if (!array_key_exists($segment, $current)) {
$current[$segment] = [];
}

$current = &$current[$segment];
}

$current = $value;
}

/**
* Check if path exists in nested array using dot notation path segments.
*
* @param array<string, mixed> $data
* @param array<int, string> $segments
*/
private function hasByPath(array $data, array $segments): bool
{
$current = $data;

foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return false;
}
$current = $current[$segment];
}

return true;
}

/**
* Remove path from nested array using dot notation path segments.
*
* @param array<string, mixed> $data
* @param array<int, string> $segments
*/
private function unsetByPath(array &$data, array $segments): bool
{
if (empty($segments)) {
return false;
}

$lastSegment = array_pop($segments);
$current = &$data;

// Navigate to parent of target
foreach ($segments as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return false; // Path doesn't exist
}
$current = &$current[$segment];
}

if (!is_array($current) || !array_key_exists($lastSegment, $current)) {
return false; // Target doesn't exist
}

unset($current[$lastSegment]);
return true;
}

//
// File Operations

/**
* Read inventory YAML into a structured array.
*
* @return array<string, mixed>
*/
private function readInventory(): array
{
$path = $this->inventoryPath;

if (!$this->filesystem->exists($path)) {
return [];
}

$raw = $this->filesystem->readFile($path);
$parsed = Yaml::parse($raw);

/** @var array<string, mixed> $result */
$result = is_array($parsed) ? $parsed : [];
return $result;
}
Comment on lines +238 to +244

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Undefined Filesystem::readFile call will fatal at runtime.

Symfony\Component\Filesystem\Filesystem does not provide a readFile() method, so this call will immediately trigger “Call to undefined method Filesystem::readFile()” in production. Please switch to a supported way of reading the file (e.g., wrap file_get_contents with proper error handling) before parsing the YAML.

Apply this diff to fix the problem:

-        $raw = $this->filesystem->readFile($path);
+        $raw = @file_get_contents($path);
+        if ($raw === false) {
+            throw new \RuntimeException("Failed to read inventory file at {$path}");
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$raw = $this->filesystem->readFile($path);
$parsed = Yaml::parse($raw);
/** @var array<string, mixed> $result */
$result = is_array($parsed) ? $parsed : [];
return $result;
}
$raw = @file_get_contents($path);
if ($raw === false) {
throw new \RuntimeException("Failed to read inventory file at {$path}");
}
$parsed = Yaml::parse($raw);
/** @var array<string, mixed> $result */
$result = is_array($parsed) ? $parsed : [];
return $result;
}
🤖 Prompt for AI Agents
In app/Services/InventoryService.php around lines 238 to 244, the code calls
$this->filesystem->readFile($path) which does not exist on Symfony's Filesystem
and will fatal; replace that call with a safe file read: check file_exists and
is_readable($path) (or use @file_get_contents with error check), retrieve the
file contents into a variable, throw or log a descriptive error if reading
fails, only pass non-false content to Yaml::parse and guard that parse result is
an array before returning it (otherwise return an empty array); ensure any
exceptions from YAML parsing are caught or allowed to bubble with context so
runtime failures are handled cleanly.


/**
* Persist inventory data to YAML file.
*
* @param array<string, mixed> $inventory
*/
private function writeInventory(array $inventory): void
{
$path = $this->inventoryPath;

if (!$this->filesystem->exists($this->inventoryDir)) {
try {
$this->filesystem->mkdir($this->inventoryDir, 0775);
} catch (\Throwable $e) {
throw new \RuntimeException("Unable to create inventory directory: {$this->inventoryDir}", 0, $e);
}
}

$yaml = Yaml::dump($inventory, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
try {
$this->filesystem->dumpFile($path, $yaml);
} catch (\Throwable $e) {
throw new \RuntimeException("Failed to write inventory file at {$path}", 0, $e);
}
}
}
53 changes: 53 additions & 0 deletions tests/TestHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

declare(strict_types=1);

use Symfony\Component\Filesystem\Filesystem;

if (!function_exists('setEnv')) {
/**
* Set or unset environment variables for testing.
Expand All @@ -18,3 +20,54 @@ function setEnv(string $key, ?string $value): void
}
}
}

if (!function_exists('mockFilesystem')) {
/**
* Create a mock filesystem for testing with comprehensive error simulation.
*/
function mockFilesystem(
bool $exists = true,
string $content = '',
bool $throwOnRead = false,
bool $throwOnMkdir = false,
bool $throwOnDump = false
): Filesystem {
return new class ($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump) extends Filesystem {
public function __construct(
private readonly bool $exists,
private readonly string $content,
private readonly bool $throwOnRead,
private readonly bool $throwOnMkdir,
private readonly bool $throwOnDump
) {
}

public function exists(string|iterable $files): bool
{
return $this->exists;
}

public function readFile(string $filename): string
{
if ($this->throwOnRead) {
throw new \RuntimeException('Permission denied');
}
return $this->content;
}

public function mkdir($dirs, int $mode = 0777): void
{
if ($this->throwOnMkdir) {
throw new \Exception('Permission denied');
}
}

public function dumpFile(string $filename, $content): void
{
if ($this->throwOnDump) {
throw new \Exception('Write failed');
}
}
};
}
}
Loading