From fa6475afda014ee315ec207ff1b0148fc29d26e1 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sat, 13 Jun 2026 04:43:23 +0300 Subject: [PATCH] feat(command): add #[Group] and #[SingleInstance] attributes - Add WebFiori\Cli\Attributes\Group attribute for help display grouping - Add WebFiori\Cli\Attributes\SingleInstance attribute for lock-based single-instance enforcement - Add LockManager class using flock() for non-blocking exclusive locks - Add getGroup()/setGroup() to Command - Add resolveGroup() with precedence: explicit > attribute > colon convention - Integrate SingleInstance into excCommand() with try/finally lock release - Update HelpCommand to display commands in groups when present - Establish WebFiori\Cli\Attributes\ namespace per ADR-0025 Closes #47 Closes #49 --- WebFiori/Cli/Attributes/Group.php | 20 + WebFiori/Cli/Attributes/SingleInstance.php | 25 ++ WebFiori/Cli/Command.php | 116 +++++- WebFiori/Cli/Commands/HelpCommand.php | 371 ++++++++++-------- WebFiori/Cli/LockManager.php | 94 +++++ WebFiori/Cli/Runner.php | 3 + .../Tests/Cli/CommandAttributesTest.php | 242 ++++++++++++ tests/WebFiori/Tests/Cli/LockManagerTest.php | 94 +++++ 8 files changed, 790 insertions(+), 175 deletions(-) create mode 100644 WebFiori/Cli/Attributes/Group.php create mode 100644 WebFiori/Cli/Attributes/SingleInstance.php create mode 100644 WebFiori/Cli/LockManager.php create mode 100644 tests/WebFiori/Tests/Cli/CommandAttributesTest.php create mode 100644 tests/WebFiori/Tests/Cli/LockManagerTest.php diff --git a/WebFiori/Cli/Attributes/Group.php b/WebFiori/Cli/Attributes/Group.php new file mode 100644 index 0000000..1d80b3f --- /dev/null +++ b/WebFiori/Cli/Attributes/Group.php @@ -0,0 +1,20 @@ +name = $name; + } +} diff --git a/WebFiori/Cli/Attributes/SingleInstance.php b/WebFiori/Cli/Attributes/SingleInstance.php new file mode 100644 index 0000000..63760c6 --- /dev/null +++ b/WebFiori/Cli/Attributes/SingleInstance.php @@ -0,0 +1,25 @@ +lockPath = $lockPath; + $this->exitCode = $exitCode; + } +} diff --git a/WebFiori/Cli/Command.php b/WebFiori/Cli/Command.php index aef9604..370c7fd 100644 --- a/WebFiori/Cli/Command.php +++ b/WebFiori/Cli/Command.php @@ -45,6 +45,11 @@ abstract class Command { * @var string */ private $description; + /** + * The group this command belongs to for help display. + * @var string|null + */ + private $group; /** * * @var InputStream @@ -99,6 +104,7 @@ public function __construct(string $commandName, array $args = [], string $descr } $this->aliases = $aliases; $this->signalHandlers = []; + $this->group = null; $this->addArgs($args); if (!$this->setDescription($description)) { @@ -381,6 +387,7 @@ public function error(string $message): void { */ public function excCommand() : int { $retVal = -1; + $lockManager = null; $runner = $this->getOwner(); @@ -390,20 +397,45 @@ public function excCommand() : int { } } - if ($this->parseArgsHelper()) { - // Check for help first, before validating required arguments - if ($this->isArgProvided('help') || $this->isArgProvided('-h')) { - $help = $runner->getCommandByName('help'); - $help->setArgValue('--command', $this->getName()); - $help->setOwner($runner); - $help->setOutputStream($runner->getOutputStream()); - $this->removeArgument('help'); - - return $help->exec(); - } else if ($this->checkIsArgsSetHelper()) { - $retVal = $this->exec(); + // Check for SingleInstance attribute + $singleInstance = $this->resolveSingleInstance(); + + if ($singleInstance !== null) { + $lockManager = new LockManager(); + + if (!$lockManager->acquire($this->getName(), $singleInstance->lockPath)) { + $this->warning('Command is already running.'); + + if ($runner !== null) { + foreach ($runner->getArgs() as $arg) { + $this->removeArgument($arg->getName()); + $arg->resetValue(); + } + } + + return $singleInstance->exitCode; } + } + try { + if ($this->parseArgsHelper()) { + // Check for help first, before validating required arguments + if ($this->isArgProvided('help') || $this->isArgProvided('-h')) { + $help = $runner->getCommandByName('help'); + $help->setArgValue('--command', $this->getName()); + $help->setOwner($runner); + $help->setOutputStream($runner->getOutputStream()); + $this->removeArgument('help'); + + $retVal = $help->exec(); + } else if ($this->checkIsArgsSetHelper()) { + $retVal = $this->exec(); + } + } + } finally { + if ($lockManager !== null) { + $lockManager->release(); + } } if ($runner !== null) { @@ -546,6 +578,15 @@ public function getDescription() : string { return $this->description; } + /** + * Returns the group this command belongs to. + * + * @return string|null The group name, or null if ungrouped. + */ + public function getGroup(): ?string { + return $this->group; + } + /** * Take an input value from the user. * @@ -1100,6 +1141,32 @@ public function removeArgument(string $name) : bool { return $removed; } + /** + * Resolves the group for this command from attributes or name convention. + * + * Priority: 1. Explicit setGroup() 2. #[Group] attribute 3. Colon prefix in name + */ + public function resolveGroup(): void { + if ($this->group !== null) { + return; + } + + $ref = new ReflectionClass($this); + $attrs = $ref->getAttributes(Attributes\Group::class); + + if (count($attrs) > 0) { + $this->group = $attrs[0]->newInstance()->name; + + return; + } + + $colonPos = strpos($this->getName(), ':'); + + if ($colonPos !== false) { + $this->group = substr($this->getName(), 0, $colonPos); + } + } + /** * Ask the user to select one of multiple values. * @@ -1214,6 +1281,15 @@ public function setDescription(string $str) : bool { return false; } + + /** + * Sets the group this command belongs to for help display organization. + * + * @param string $group The group name. + */ + public function setGroup(string $group): void { + $this->group = $group; + } /** * Sets the stream at which the command will read input from. * @@ -1782,4 +1858,20 @@ private function readMaskedLine(string $mask = '*'): string { return $input; } + + /** + * Resolves the SingleInstance attribute on this command class. + * + * @return Attributes\SingleInstance|null The attribute instance, or null if not present. + */ + private function resolveSingleInstance(): ?Attributes\SingleInstance { + $ref = new ReflectionClass($this); + $attrs = $ref->getAttributes(Attributes\SingleInstance::class); + + if (count($attrs) > 0) { + return $attrs[0]->newInstance(); + } + + return null; + } } diff --git a/WebFiori/Cli/Commands/HelpCommand.php b/WebFiori/Cli/Commands/HelpCommand.php index e7f4eb8..9ccdf4e 100644 --- a/WebFiori/Cli/Commands/HelpCommand.php +++ b/WebFiori/Cli/Commands/HelpCommand.php @@ -1,163 +1,208 @@ - [ - ArgumentOption::OPTIONAL => true, - ArgumentOption::DESCRIPTION => 'An optional command name. If provided, help ' - .'will be specific to the given command only.' - ], - '--table' => [ - ArgumentOption::OPTIONAL => true, - ArgumentOption::DESCRIPTION => 'Display command arguments in table format for better readability.' - ] - ], 'Display CLI Help. To display help for specific command, use the argument ' - .'"--command" with this command.', ['-h']); - } - /** - * Execute the command. - * - */ - public function exec() : int { - $regCommands = $this->getOwner()->getCommands(); - $commandName = $this->getArgValue('--command'); - $len = $this->getMaxCommandNameLen(); - - if ($commandName !== null) { - if (isset($regCommands[$commandName])) { - $this->printCommandInfo($regCommands[$commandName], $len, true); - } else { - $this->error("Command '$commandName' is not supported."); - } - } else { - $formattingOptions = [ - 'bold' => true, - 'color' => 'light-yellow' - ]; - $this->println("Usage:", $formattingOptions); - $this->println(" command [arg1 arg2=\"val\" arg3...]\n"); - $this->printGlobalArgs($formattingOptions); - $this->println("Available Commands:", $formattingOptions); - - foreach ($regCommands as $commandObj) { - $this->printCommandInfo($commandObj, $len); - } - } - - return 0; - } - private function getMaxCommandNameLen() : int { - $len = 0; - - foreach ($this->getOwner()->getCommands() as $c) { - $xLen = strlen($c->getName()); - - if ($xLen > $len) { - $len = $xLen; - } - } - - return $len; - } - private function printArg(Argument $argObj, $spaces = 25) { - $this->prints(" %".$spaces."s:", $argObj->getName(), [ - 'bold' => true, - 'color' => 'yellow' - ]); - - if ($argObj->isOptional()) { - $this->prints("[Optional]"); - } - - if ($argObj->getDefault() != '') { - $default = $argObj->getDefault(); - $this->prints("[Default = '$default']"); - } - $this->println(" %s", $argObj->getDescription()); - } - - private function printArgsTable(array $args) { - $rows = []; - foreach ($args as $argObj) { - $name = $argObj->getName(); - $required = $argObj->isOptional() ? 'No' : 'Yes'; - $default = $argObj->getDefault() ?: '-'; - $description = $argObj->getDescription() ?: ''; - - $rows[] = [$name, $required, $default, $description]; - } - - $this->table($rows, ['Argument', 'Required', 'Default', 'Description']); - } - - /** - * Prints meta information of a specific command. - * - * @param Command $cliCommand - * - * @param int $len - * - * @param bool $withArgs - */ - private function printCommandInfo(Command $cliCommand, int $len, bool $withArgs = false) { - $this->prints(" %s", $cliCommand->getName(), [ - 'color' => 'yellow', - 'bold' => true - ]); - $this->prints(': '); - $spacesCount = $len - strlen($cliCommand->getName()) + 4; - $this->println(str_repeat(' ', $spacesCount)."%s", $cliCommand->getDescription()); - - if ($withArgs) { - $args = array_filter($cliCommand->getArgs(), function($arg) { - return !in_array($arg->getName(), ['help', '-h']); - }); - - if (count($args) != 0) { - $this->println(" Supported Arguments:", [ - 'bold' => true, - 'color' => 'light-blue' - ]); - - if ($this->getArgValue('--table') !== null) { - $this->printArgsTable($args); - } else { - foreach ($args as $argObj) { - $this->printArg($argObj); - } - } - } - } - } - private function printGlobalArgs(array $formattingOptions) { - $args = $this->getOwner()->getArgs(); - - if (count($args) != 0) { - $this->println("Global Arguments:", $formattingOptions); - - foreach ($args as $argObj) { - $this->printArg($argObj, 4); - } - } - } -} + [ + ArgumentOption::OPTIONAL => true, + ArgumentOption::DESCRIPTION => 'An optional command name. If provided, help ' + .'will be specific to the given command only.' + ], + '--table' => [ + ArgumentOption::OPTIONAL => true, + ArgumentOption::DESCRIPTION => 'Display command arguments in table format for better readability.' + ] + ], 'Display CLI Help. To display help for specific command, use the argument ' + .'"--command" with this command.', ['-h']); + } + /** + * Execute the command. + * + */ + public function exec() : int { + $regCommands = $this->getOwner()->getCommands(); + $commandName = $this->getArgValue('--command'); + $len = $this->getMaxCommandNameLen(); + + if ($commandName !== null) { + if (isset($regCommands[$commandName])) { + $this->printCommandInfo($regCommands[$commandName], $len, true); + } else { + $this->error("Command '$commandName' is not supported."); + } + } else { + $formattingOptions = [ + 'bold' => true, + 'color' => 'light-yellow' + ]; + $this->println("Usage:", $formattingOptions); + $this->println(" command [arg1 arg2=\"val\" arg3...]\n"); + $this->printGlobalArgs($formattingOptions); + $this->println("Available Commands:", $formattingOptions); + + $this->printCommandsGrouped($regCommands, $len, $formattingOptions); + } + + return 0; + } + private function getMaxCommandNameLen() : int { + $len = 0; + + foreach ($this->getOwner()->getCommands() as $c) { + $xLen = strlen($c->getName()); + + if ($xLen > $len) { + $len = $xLen; + } + } + + return $len; + } + private function printArg(Argument $argObj, $spaces = 25) { + $this->prints(" %".$spaces."s:", $argObj->getName(), [ + 'bold' => true, + 'color' => 'yellow' + ]); + + if ($argObj->isOptional()) { + $this->prints("[Optional]"); + } + + if ($argObj->getDefault() != '') { + $default = $argObj->getDefault(); + $this->prints("[Default = '$default']"); + } + $this->println(" %s", $argObj->getDescription()); + } + + private function printArgsTable(array $args) { + $rows = []; + + foreach ($args as $argObj) { + $name = $argObj->getName(); + $required = $argObj->isOptional() ? 'No' : 'Yes'; + $default = $argObj->getDefault() ?: '-'; + $description = $argObj->getDescription() ?: ''; + + $rows[] = [$name, $required, $default, $description]; + } + + $this->table($rows, ['Argument', 'Required', 'Default', 'Description']); + } + + /** + * Prints meta information of a specific command. + * + * @param Command $cliCommand + * + * @param int $len + * + * @param bool $withArgs + */ + private function printCommandInfo(Command $cliCommand, int $len, bool $withArgs = false) { + $this->prints(" %s", $cliCommand->getName(), [ + 'color' => 'yellow', + 'bold' => true + ]); + $this->prints(': '); + $spacesCount = $len - strlen($cliCommand->getName()) + 4; + $this->println(str_repeat(' ', $spacesCount)."%s", $cliCommand->getDescription()); + + if ($withArgs) { + $args = array_filter($cliCommand->getArgs(), function ($arg) { + return !in_array($arg->getName(), ['help', '-h']); + }); + + if (count($args) != 0) { + $this->println(" Supported Arguments:", [ + 'bold' => true, + 'color' => 'light-blue' + ]); + + if ($this->getArgValue('--table') !== null) { + $this->printArgsTable($args); + } else { + foreach ($args as $argObj) { + $this->printArg($argObj); + } + } + } + } + } + + private function printCommandsGrouped(array $commands, int $len, array $formattingOptions) { + $grouped = []; + $ungrouped = []; + + foreach ($commands as $commandObj) { + $group = $commandObj->getGroup(); + + if ($group !== null) { + $grouped[$group][] = $commandObj; + } else { + $ungrouped[] = $commandObj; + } + } + + if (count($grouped) == 0) { + // No groups — flat list + foreach ($ungrouped as $commandObj) { + $this->printCommandInfo($commandObj, $len); + } + + return; + } + + // Print grouped commands first + ksort($grouped); + + foreach ($grouped as $groupName => $groupCommands) { + $this->println(" %s:", $groupName, [ + 'bold' => true, + 'color' => 'light-blue' + ]); + + foreach ($groupCommands as $commandObj) { + $this->printCommandInfo($commandObj, $len); + } + } + + // Print ungrouped commands + if (count($ungrouped) > 0) { + foreach ($ungrouped as $commandObj) { + $this->printCommandInfo($commandObj, $len); + } + } + } + private function printGlobalArgs(array $formattingOptions) { + $args = $this->getOwner()->getArgs(); + + if (count($args) != 0) { + $this->println("Global Arguments:", $formattingOptions); + + foreach ($args as $argObj) { + $this->printArg($argObj, 4); + } + } + } +} diff --git a/WebFiori/Cli/LockManager.php b/WebFiori/Cli/LockManager.php new file mode 100644 index 0000000..23c3096 --- /dev/null +++ b/WebFiori/Cli/LockManager.php @@ -0,0 +1,94 @@ +handle = null; + $this->lockPath = null; + } + + /** + * Attempts to acquire an exclusive non-blocking lock. + * + * @param string $commandName The command name used to generate the lock file path. + * + * @param string|null $customPath Optional custom lock file path. + * + * @return bool True if the lock was acquired, false otherwise. + */ + public function acquire(string $commandName, ?string $customPath = null): bool { + $this->lockPath = $customPath ?? sys_get_temp_dir().'/wfcli-'.$commandName.'.lock'; + + $handle = @fopen($this->lockPath, 'w'); + + if ($handle === false) { + return false; + } + + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + + return false; + } + + $this->handle = $handle; + fwrite($this->handle, (string) getmypid()); + fflush($this->handle); + + return true; + } + + /** + * Returns the lock file path. + * + * @return string|null The path, or null if no lock has been attempted. + */ + public function getLockPath(): ?string { + return $this->lockPath; + } + + /** + * Checks if the lock is currently held. + * + * @return bool True if a lock is held. + */ + public function isLocked(): bool { + return $this->handle !== null; + } + + /** + * Releases the lock and closes the file handle. + */ + public function release(): void { + if ($this->handle !== null) { + flock($this->handle, LOCK_UN); + fclose($this->handle); + $this->handle = null; + } + + if ($this->lockPath !== null && file_exists($this->lockPath)) { + @unlink($this->lockPath); + $this->lockPath = null; + } + } +} diff --git a/WebFiori/Cli/Runner.php b/WebFiori/Cli/Runner.php index 940e223..be994db 100644 --- a/WebFiori/Cli/Runner.php +++ b/WebFiori/Cli/Runner.php @@ -729,6 +729,9 @@ public function register(Command $cliCommand, array $aliases = []): Runner { } $this->commands[$cliCommand->getName()] = $cliCommand; + // Resolve group from attribute or name convention + $cliCommand->resolveGroup(); + // Register runtime aliases foreach ($aliases as $alias) { $this->registerAlias($alias, $cliCommand->getName()); diff --git a/tests/WebFiori/Tests/Cli/CommandAttributesTest.php b/tests/WebFiori/Tests/Cli/CommandAttributesTest.php new file mode 100644 index 0000000..d481207 --- /dev/null +++ b/tests/WebFiori/Tests/Cli/CommandAttributesTest.php @@ -0,0 +1,242 @@ +println('running'); + + return 0; + } +} + +#[SingleInstance(lockPath: null, exitCode: 42)] +class SingleInstanceCustomExitCommand extends Command { + public function __construct() { + parent::__construct('locked-custom', [], 'Custom exit code'); + } + + public function exec(): int { + $this->println('running'); + + return 0; + } +} + +#[Group('db')] +class DbMigrateCommand extends Command { + public function __construct() { + parent::__construct('db:migrate', [], 'Run database migrations'); + } + + public function exec(): int { + $this->println('migrating'); + + return 0; + } +} + +#[Group('db')] +class DbSeedCommand extends Command { + public function __construct() { + parent::__construct('db:seed', [], 'Seed the database'); + } + + public function exec(): int { + return 0; + } +} + +class UngroupedCommand extends Command { + public function __construct() { + parent::__construct('serve', [], 'Start dev server'); + } + + public function exec(): int { + return 0; + } +} + +class ColonGroupCommand extends Command { + public function __construct() { + parent::__construct('cache:clear', [], 'Clear the cache'); + } + + public function exec(): int { + return 0; + } +} + +class CommandAttributesTest extends CommandTestCase { + /** + * @test + */ + public function testSingleInstanceRunsNormally() { + $output = $this->executeSingleCommand(new SingleInstanceCommand()); + $this->assertContains("running\n", $output); + $this->assertEquals(0, $this->getExitCode()); + } + + /** + * @test + */ + public function testSingleInstanceBlocksConcurrent() { + // Acquire lock manually, then try to run the command + $lm = new \WebFiori\Cli\LockManager(); + $this->assertTrue($lm->acquire('locked-cmd')); + + $output = $this->executeSingleCommand(new SingleInstanceCommand()); + $outputStr = implode('', $output); + + $this->assertStringContainsString('already running', $outputStr); + $this->assertEquals(1, $this->getExitCode()); + + $lm->release(); + } + + /** + * @test + */ + public function testSingleInstanceCustomExitCode() { + $lm = new \WebFiori\Cli\LockManager(); + $this->assertTrue($lm->acquire('locked-custom')); + + $this->executeSingleCommand(new SingleInstanceCustomExitCommand()); + $this->assertEquals(42, $this->getExitCode()); + + $lm->release(); + } + + /** + * @test + */ + public function testSingleInstanceReleasesLockAfterExec() { + $this->executeSingleCommand(new SingleInstanceCommand()); + + // Lock should be released — can acquire again + $lm = new \WebFiori\Cli\LockManager(); + $this->assertTrue($lm->acquire('locked-cmd')); + $lm->release(); + } + + /** + * @test + */ + public function testGroupAttributeResolved() { + $cmd = new DbMigrateCommand(); + $runner = new Runner(); + $runner->reset(); + $runner->register($cmd); + + $this->assertEquals('db', $cmd->getGroup()); + } + + /** + * @test + */ + public function testGroupFromColonConvention() { + $cmd = new ColonGroupCommand(); + $runner = new Runner(); + $runner->reset(); + $runner->register($cmd); + + $this->assertEquals('cache', $cmd->getGroup()); + } + + /** + * @test + */ + public function testExplicitGroupOverridesAttribute() { + $cmd = new DbMigrateCommand(); + $cmd->setGroup('custom'); + $runner = new Runner(); + $runner->reset(); + $runner->register($cmd); + + // Explicit setGroup should win + $this->assertEquals('custom', $cmd->getGroup()); + } + + /** + * @test + */ + public function testUngroupedCommandHasNullGroup() { + $cmd = new UngroupedCommand(); + $runner = new Runner(); + $runner->reset(); + $runner->register($cmd); + + $this->assertNull($cmd->getGroup()); + } + + /** + * @test + */ + public function testHelpOutputGrouped() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new DbMigrateCommand()); + $runner->register(new DbSeedCommand()); + $runner->register(new UngroupedCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'help']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + // Should show group header + $this->assertStringContainsString('db:', $outputStr); + // Should contain commands + $this->assertStringContainsString('db:migrate', $outputStr); + $this->assertStringContainsString('db:seed', $outputStr); + $this->assertStringContainsString('serve', $outputStr); + } + + /** + * @test + */ + public function testHelpOutputNoGroupsFlatList() { + $runner = new Runner(); + $runner->reset(); + $runner->register(new UngroupedCommand()); + $runner->setInputs([]); + $runner->setArgsVector(['main.php', 'help']); + $runner->start(); + + $output = $runner->getOutput(); + $outputStr = implode('', $output); + + // No group headers for ungrouped-only output + $this->assertStringContainsString('serve', $outputStr); + } + + /** + * @test + */ + public function testGetGroupDefaultNull() { + $cmd = new UngroupedCommand(); + $this->assertNull($cmd->getGroup()); + } + + /** + * @test + */ + public function testSetGroup() { + $cmd = new UngroupedCommand(); + $cmd->setGroup('mygroup'); + $this->assertEquals('mygroup', $cmd->getGroup()); + } +} diff --git a/tests/WebFiori/Tests/Cli/LockManagerTest.php b/tests/WebFiori/Tests/Cli/LockManagerTest.php new file mode 100644 index 0000000..63ac74c --- /dev/null +++ b/tests/WebFiori/Tests/Cli/LockManagerTest.php @@ -0,0 +1,94 @@ +assertFalse($lm->isLocked()); + $this->assertNull($lm->getLockPath()); + } + + /** + * @test + */ + public function testAcquireAndRelease() { + $lm = new LockManager(); + $this->assertTrue($lm->acquire('test-cmd')); + $this->assertTrue($lm->isLocked()); + $this->assertNotNull($lm->getLockPath()); + $this->assertStringContainsString('wfcli-test-cmd.lock', $lm->getLockPath()); + + $lm->release(); + $this->assertFalse($lm->isLocked()); + } + + /** + * @test + */ + public function testCustomLockPath() { + $path = sys_get_temp_dir() . '/custom-test-lock.lock'; + $lm = new LockManager(); + $this->assertTrue($lm->acquire('ignored', $path)); + $this->assertEquals($path, $lm->getLockPath()); + + $lm->release(); + } + + /** + * @test + */ + public function testConcurrentAcquireFails() { + $lm1 = new LockManager(); + $lm2 = new LockManager(); + + $this->assertTrue($lm1->acquire('concurrent-test')); + $this->assertFalse($lm2->acquire('concurrent-test')); + + $lm1->release(); + + // Now second can acquire + $this->assertTrue($lm2->acquire('concurrent-test')); + $lm2->release(); + } + + /** + * @test + */ + public function testReleaseWithoutAcquire() { + $lm = new LockManager(); + // Should not throw + $lm->release(); + $this->assertFalse($lm->isLocked()); + } + + /** + * @test + */ + public function testLockFileContainsPid() { + $lm = new LockManager(); + $lm->acquire('pid-test'); + $path = $lm->getLockPath(); + $content = file_get_contents($path); + $this->assertEquals((string) getmypid(), $content); + + $lm->release(); + } + + /** + * @test + */ + public function testAcquireFailsOnInvalidPath() { + $lm = new LockManager(); + $result = $lm->acquire('test', '/nonexistent/dir/lock.lock'); + $this->assertFalse($result); + $this->assertFalse($lm->isLocked()); + } +}