firstInvalidLine("APP_NAME=CluPilot\n\n# a comment\nDB_HOST=mariadb\n"))->toBeNull() ->and($editor->isValid("APP_NAME=CluPilot\n"))->toBeTrue() // A value may itself contain '=' — only the first one delimits the key. ->and($editor->isValid('MIX=a=b=c'))->toBeTrue() // A key may have an empty value. ->and($editor->isValid('EMPTY_OK='))->toBeTrue(); // Neither blank, nor a comment, nor KEY=value — the exact line named in // the task, and 1-indexed so it can be shown to an operator directly. expect($editor->firstInvalidLine("APP_NAME=CluPilot\nthis is not env syntax\nDB_HOST=mariadb\n"))->toBe(2) ->and($editor->isValid("APP_NAME=CluPilot\nthis is not env syntax\n"))->toBeFalse(); }); it('refuses to write invalid content, and never touches the file', function () { // Mutation target: an invalid file is refused rather than written. $path = envEditorPath(); File::put($path, "ORIGINAL=untouched\n"); $editor = new EnvFileEditor($path); expect(fn () => $editor->write("ORIGINAL=untouched\nnot env syntax at all\n")) ->toThrow(InvalidEnvContentException::class); // The file on disk is EXACTLY what it was before the rejected write — // not merely "still has ORIGINAL in it somewhere". expect(File::get($path))->toBe("ORIGINAL=untouched\n") // And no backup was created either: nothing was about to change. ->and(glob($path.'.bak-*'))->toBe([]); }); it('refuses an empty file even though it is syntactically valid', function () { $path = envEditorPath(); File::put($path, "ORIGINAL=untouched\n"); $editor = new EnvFileEditor($path); expect(fn () => $editor->write(''))->toThrow(InvalidEnvContentException::class) ->and(fn () => $editor->write(" \n\n"))->toThrow(InvalidEnvContentException::class); expect(File::get($path))->toBe("ORIGINAL=untouched\n"); }); it('backs up the previous content before writing the new content', function () { // Mutation target: a backup really exists after a save. $path = envEditorPath(); File::put($path, "OLD_KEY=old-value\n"); $editor = new EnvFileEditor($path); $backupPath = $editor->write("NEW_KEY=new-value\n"); expect(File::exists($backupPath))->toBeTrue() ->and(File::get($backupPath))->toBe("OLD_KEY=old-value\n") // The backup sits BESIDE the file, under the same name plus a suffix // — not in some other directory an operator would have to be told // about separately. ->and(dirname($backupPath))->toBe(dirname($path)) ->and(File::get($path))->toBe("NEW_KEY=new-value\n"); }); it('does not collide when two writes land in the same second', function () { $path = envEditorPath(); File::put($path, "V=1\n"); $editor = new EnvFileEditor($path); $first = $editor->write("V=2\n"); $second = $editor->write("V=3\n"); expect($first)->not->toBe($second) ->and(File::exists($first))->toBeTrue() ->and(File::exists($second))->toBeTrue(); }); it('creates the file when none existed before, without trying to back up something that was never there', function () { // Codex review, P2: File::copy() on a source that does not exist throws, // which made the first save on an installation with no .env yet fail // outright instead of creating one. $path = envEditorPath(); File::delete($path); // envEditorPath() seeds it — deliberately undone here. $editor = new EnvFileEditor($path); $backup = $editor->write("FRESH=value\n"); expect($backup)->toBe('') ->and(File::get($path))->toBe("FRESH=value\n"); }); it('answers empty rather than throwing when asked to back up a file that does not exist', function () { $path = envEditorPath(); File::delete($path); expect((new EnvFileEditor($path))->backup())->toBe(''); }); it('defaults to base_path(.env) when no path is given', function () { expect((new EnvFileEditor)->path())->toBe(base_path('.env')); }); it('reads empty rather than failing when the file does not exist yet', function () { $editor = new EnvFileEditor(envEditorPath()); expect($editor->read())->toBe(''); });