From 2f11eb20d327fea6c0e7991d134a98e5a09d2d91 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 27 May 2026 01:34:56 -0700 Subject: [PATCH] fix: resolve home directory via cross-platform helper (partial #68) InitCommand and YamlConfigProvider read \`getenv('HOME')\` directly to expand the leading \`~\` in the default config path. On Windows that env var is usually unset (the OS uses \`USERPROFILE\` plus optional \`HOMEDRIVE\` / \`HOMEPATH\` as the fallback chain), so \`~/.config/...\` quietly resolved to \`/.config/...\` at filesystem root and \`init\` / \`work:report\` silently misbehaved. Introduce \`Igancev\\WorkReporter\\Platform\\HomeDirectory::resolve()\` that returns the canonical home path per platform: \`HOME\` on POSIX, \`USERPROFILE\` on Windows with the documented \`HOMEDRIVE\` + \`HOMEPATH\` fallback for containers/services that strip \`USERPROFILE\`. The helper returns an empty string when nothing is set so callers see the same shape as the prior \`(string)getenv(...)\` cast. Scope: only the env-var leg of #68's checklist. CI workflow for the Windows binary, README install section, and end-to-end Windows binary verification are intentionally left for follow-up PRs so each item stays atomic per CLAUDE.md / CONTRIBUTING.md guidance. Verification: vendor/bin/phpunit tests/Unit/Platform/HomeDirectoryTest.php vendor/bin/phpunit tests/Unit/InitCommandTest.php \ tests/Unit/Config/YamlConfigProviderTest.php make cs make stat-analyze all pass on macOS (POSIX path); the Windows-only assertions are skipped on POSIX runners and exercised on a future Windows CI lane. --- src/Cli/InitCommand.php | 3 +- src/Config/YamlConfigProvider.php | 3 +- src/Platform/HomeDirectory.php | 37 +++++++ tests/Unit/Platform/HomeDirectoryTest.php | 114 ++++++++++++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/Platform/HomeDirectory.php create mode 100644 tests/Unit/Platform/HomeDirectoryTest.php diff --git a/src/Cli/InitCommand.php b/src/Cli/InitCommand.php index 3b57628..4cecc1f 100644 --- a/src/Cli/InitCommand.php +++ b/src/Cli/InitCommand.php @@ -4,6 +4,7 @@ namespace Igancev\WorkReporter\Cli; +use Igancev\WorkReporter\Platform\HomeDirectory; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -55,7 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $home = (string) getenv('HOME'); + $home = HomeDirectory::resolve(); $configDir = str_replace('~', $home, self::DEFAULT_CONFIG_DIR); $configPath = str_replace('~', $home, self::DEFAULT_CONFIG_PATH); diff --git a/src/Config/YamlConfigProvider.php b/src/Config/YamlConfigProvider.php index 543b2be..1cef4c3 100644 --- a/src/Config/YamlConfigProvider.php +++ b/src/Config/YamlConfigProvider.php @@ -8,6 +8,7 @@ use Igancev\WorkReporter\Config\SourceConfig\SourcesConfig; use Igancev\WorkReporter\Config\SourceConfig\SuperProductivityConfig; use Igancev\WorkReporter\Destination\DestinationType; +use Igancev\WorkReporter\Platform\HomeDirectory; use Igancev\WorkReporter\Source\SourceType; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -28,7 +29,7 @@ public function getConfig(): Config return $this->config; } - $path = str_replace("~", (string)getenv("HOME"), $this->configPath); + $path = str_replace("~", HomeDirectory::resolve(), $this->configPath); if (!file_exists($path)) { throw new ConfigException(sprintf( diff --git a/src/Platform/HomeDirectory.php b/src/Platform/HomeDirectory.php new file mode 100644 index 0000000..04f42fd --- /dev/null +++ b/src/Platform/HomeDirectory.php @@ -0,0 +1,37 @@ +originalHome = getenv('HOME'); + $this->originalUserProfile = getenv('USERPROFILE'); + $this->originalHomeDrive = getenv('HOMEDRIVE'); + $this->originalHomePath = getenv('HOMEPATH'); + } + + protected function tearDown(): void + { + $this->restoreEnv('HOME', $this->originalHome); + $this->restoreEnv('USERPROFILE', $this->originalUserProfile); + $this->restoreEnv('HOMEDRIVE', $this->originalHomeDrive); + $this->restoreEnv('HOMEPATH', $this->originalHomePath); + } + + public function testResolveReturnsHomeOnPosix(): void + { + if (PHP_OS_FAMILY === 'Windows') { + self::markTestSkipped('POSIX-only path; Windows uses USERPROFILE.'); + } + + // Arrange + putenv('HOME=/home/posix-user'); + + // Act + $home = HomeDirectory::resolve(); + + // Assert + self::assertSame('/home/posix-user', $home); + } + + public function testResolvePrefersUserProfileOnWindows(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + self::markTestSkipped('Windows-only path; POSIX uses HOME.'); + } + + // Arrange + putenv('USERPROFILE=C:\\Users\\winuser'); + putenv('HOMEDRIVE=Z:'); + putenv('HOMEPATH=\\Z-path'); + + // Act + $home = HomeDirectory::resolve(); + + // Assert + self::assertSame('C:\\Users\\winuser', $home); + } + + public function testResolveFallsBackToHomeDrivePathOnWindowsWhenUserProfileEmpty(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + self::markTestSkipped('Windows-only fallback path.'); + } + + // Arrange + putenv('USERPROFILE'); + putenv('HOMEDRIVE=C:'); + putenv('HOMEPATH=\\Users\\fallback'); + + // Act + $home = HomeDirectory::resolve(); + + // Assert + self::assertSame('C:\\Users\\fallback', $home); + } + + public function testResolveReturnsEmptyStringWhenEnvUnset(): void + { + // Arrange + if (PHP_OS_FAMILY === 'Windows') { + putenv('USERPROFILE'); + putenv('HOMEDRIVE'); + putenv('HOMEPATH'); + } else { + putenv('HOME'); + } + + // Act + $home = HomeDirectory::resolve(); + + // Assert + self::assertSame('', $home); + } + + private function restoreEnv(string $name, string|false $original): void + { + if ($original === false) { + putenv($name); + return; + } + putenv($name . '=' . $original); + } +}