diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..11885b0 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,32 @@ +# Project + +PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure +dependencies in core, small public surface area. Public API at `src/` root; implementation details +under `src/Internal/`. + +## Rules + +All coding standards, architecture, naming, testing, and documentation conventions +are defined in `rules/`. Read the applicable rule files before generating any code or documentation. + +## Commands + +- `make test` — run tests with coverage. +- `make mutation-test` — run mutation testing (Infection). +- `make review` — run lint. +- `make help` — list all available commands. + +## Post-change validation + +After any code change, run `make review`, `make test`, and `make mutation-test`. +If any fails, iterate on the fix while respecting all project rules until all pass. +Never deliver code that breaks lint, tests, or leaves surviving mutants. + +## File formatting + +Every file produced or modified must: + +- Use **LF** line endings. Never CRLF. +- Have no trailing whitespace on any line. +- End with a single trailing newline. +- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 0000000..a369ba4 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,78 @@ +--- +description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used +inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. + +## Pre-output checklist + +Verify every item before producing any workflow YAML. If any item fails, revise before outputting. + +1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. +2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash + (e.g., `CD — Run migration`, not `CD — Run Migration`). +3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. +4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. +5. Every input has a `description` field. Descriptions use American English and end with a period. +6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. +7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. +8. Choice input options are in **alphabetical order**. +9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. +10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). +11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. +12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, + `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. +13. All other YAML property names within a block are ordered by **name length ascending**. +14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. +15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. +16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, + `package.json`). No version is hardcoded in any workflow. +17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, + not in the workflows repository. +18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. +19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they + need. +20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. +21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. +22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. +23. Third-party actions are pinned to the latest available full commit SHA with a version comment: + `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest + version before generating a workflow. +24. First-party actions (`actions/*`) are pinned to the latest major version tag available: + `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. +25. Production deployments require GitHub Environments protection rules (manual approval). +26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 + minutes. Adjust only with justification in a comment. +27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid + redundant runs. +28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to + prevent interrupted deployments. +29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. +30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. + +## Style + +- All text (workflow names, step names, input descriptions, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +## Callers + +- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. +- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. +- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. + +## Image tagging + +- CD deploy builds: `-sha-` + `latest`. + +## Migrations + +- Migrations run **before** service deployment (schema first, code second). +- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. +- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md new file mode 100644 index 0000000..7ec196e --- /dev/null +++ b/.claude/rules/php-library-code-style.md @@ -0,0 +1,154 @@ +--- +description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries. +paths: + - "src/**/*.php" + - "tests/**/*.php" +--- + +# Code style + +Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` +and are not repeated here. Refer to `php-library-modeling.md` for library modeling rules. + +## Pre-output checklist + +Verify every item before producing any PHP code. If any item fails, revise before outputting. + +1. `declare(strict_types=1)` is present. +2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is + designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without + `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). +3. All parameters, return types, and properties have explicit types. +4. Constructor property promotion is used. +5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). + Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, + `assertTrue`, `expectException`, etc.). +6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. +8. No generic identifiers exist. Use domain-specific names instead: + `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, + `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. +9. No raw arrays exist where a typed collection or value object is available. Use the `tiny-blocks/collection` + fluent API (`Collection`, `Collectible`) when data is `Collectible`. Use `createLazyFrom` when elements are + consumed once. Raw arrays are acceptable only for primitive configuration data, variadic pass-through, and + interop at system boundaries. See "Collection usage" below for the full rule and example. +10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site + or extract it to a collaborator or value object. +11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each + group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have + no body, are ordered by name length ascending. +12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), + except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), + which takes precedence. Parameters with default values go last, regardless of name length. The same rule + applies to named arguments at call sites. + Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). +13. Time and space complexity are first-class design concerns. + - No `O(N²)` or worse time complexity exists unless the problem inherently requires it and the cost is + documented in PHPDoc on the interface method. + - Space complexity is kept minimal: prefer lazy/streaming pipelines (`createLazyFrom`) over materializing + intermediate collections. + - Never re-iterate the same source; fuse stages when possible. + - Public interface methods document time and space complexity in Big O form (see "PHPDoc" section). +14. No logic is duplicated across two or more places (DRY). +15. No abstraction exists without real duplication or isolation need (KISS). +16. All identifiers, comments, and documentation are written in American English. +17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. +18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. + Never leave silent gaps. +19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. +20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not + need it, it does not exist. +22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, + first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods + over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), + and `Collection::map` over foreach accumulation. +23. No vertical alignment of types in parameter lists or property declarations. Use a single space between + type and variable name. Never pad with extra spaces to align columns: + `public OrderId $id` — not `public OrderId $id`. +24. Opening brace `{` follows PSR-12: on a **new line** for classes, interfaces, traits, enums, and methods + (including constructors); on the **same line** for closures and control structures (`if`, `for`, `foreach`, + `while`, `switch`, `match`, `try`). +25. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. + Example — `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`: + `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` → `$collection->toArray()`. + Only pass the argument when the value differs from the default. +26. No trailing comma in any multi-line list. This applies to parameter lists (constructors, methods, + closures), argument lists at call sites, array literals, match arms, and any other comma-separated + multi-line structure. The last element never has a comma after it. PHP accepts trailing commas in + parameter lists, but this project prohibits them for visual consistency. + Example — correct: + ``` + new Precision( + value: 2, + rounding: RoundingMode::HALF_UP + ); + ``` + Example — prohibited: + ``` + new Precision( + value: 2, + rounding: RoundingMode::HALF_UP, + ); + ``` + +## Casing conventions + +- Internal code (variables, methods, classes): **`camelCase`**. +- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. + +## Naming + +- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. +- Generic technical verbs are avoided. See `php-library-modeling.md` — Nomenclature. +- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural: `$orders`, `$lines`. +- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. + +## Comparisons + +1. Null checks: use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings + because `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. + +## American English + +All identifiers, enum values, comments, and error codes use American English spelling: +`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), +`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), +`fulfill` (not `fulfil`), `color` (not `colour`). + +## PHPDoc + +- PHPDoc is restricted to interfaces only, documenting obligations, `@throws`, and complexity. +- Never add PHPDoc to concrete classes. +- Document `@throws` for every exception the method may raise. +- Document time and space complexity in Big O form. When a method participates in a fused pipeline (e.g., collection + pipelines), express cost as a two-part form: call-site cost + fused-pass contribution. Include a legend defining + variables (e.g., `N` for input size, `K` for number of stages). + +## Collection usage + +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions such as +`array_map`, `array_filter`, `iterator_to_array`, or `foreach` + accumulation. The same applies to `filter()`, +`reduce()`, `each()`, and all other `Collectible` operations. Chain them fluently. Never materialize with +`iterator_to_array` to then pass into a raw `array_*` function. + +**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** + +```php +$names = array_map( + static fn(Element $element): string => $element->name(), + iterator_to_array($collection) +); +``` + +**Correct — fluent chain with `map()` + `toArray()`:** + +```php +$names = $collection + ->map(transformations: static fn(Element $element): string => $element->name()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md new file mode 100644 index 0000000..d7ac6da --- /dev/null +++ b/.claude/rules/php-library-documentation.md @@ -0,0 +1,40 @@ +--- +description: Standards for README files and all project documentation in PHP libraries. +paths: + - "**/*.md" +--- + +# Documentation + +## README + +1. Include an anchor-linked table of contents. +2. Start with a concise one-line description of what the library does. +3. Include a **badges** section (license, build status, coverage, latest version, PHP version). +4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. +5. **Installation** section: Composer command (`composer require vendor/package`). +6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example + includes a brief heading describing what it demonstrates. +7. If the library exposes multiple entry points, strategies, or container types, document each with its own + subsection and example. +8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users + frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) + followed by a concise explanation. Only include entries that address real confusion points. +9. **License** and **Contributing** sections at the end. +10. Write strictly in American English. See `php-library-code-style.md` American English section for spelling + conventions. + +## Structured data + +1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, + use tables with columns: Parameter, Type, Required, Description. +2. Prefer tables to prose for any structured information. + +## Style + +1. Keep language concise and scannable. +2. Never include placeholder content (`TODO`, `TBD`). +3. Code examples must be syntactically correct and self-contained. +4. Code examples include every `use` statement needed to compile. Each example stands alone — copyable into + a fresh file without modification. +5. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md new file mode 100644 index 0000000..bedb733 --- /dev/null +++ b/.claude/rules/php-library-modeling.md @@ -0,0 +1,163 @@ +--- +description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity. +paths: + - "src/**/*.php" +--- + +# Library modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. Refer to +`php-library-code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── .php # Public enum +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ ├── .php +│ └── Exceptions/ # Internal exception classes +├── / # Feature-specific subdirectory when needed +└── Exceptions/ # Public exception classes (when part of the API) +``` + +Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Public API boundary + +Only interfaces, extension points, enums, and thin orchestration classes live at the `src/` root. These classes +define the contract consumers interact with and delegate all real work to collaborators inside `src/Internal/`. +If a class contains substantial logic (algorithms, state machines, I/O), it belongs in `Internal/`, not at the root. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Breaking changes inside `Internal/` are not semver-breaking for the library. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **concept** the library represents. A math library + uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection library uses + `Collectible`, `Order`. +2. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +3. Name methods after the operation in the library's vocabulary: `add()`, `convertTo()`, `splitAt()`. + +### Always banned + +These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names: + +- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. +- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception). + +### Anemic verbs (banned by default) + +These verbs hide what is actually happening behind a generic action. Banned unless the verb **is** the operation +that constitutes the library's reason to exist (e.g., a JSON parser may have `parse()`; a hashing library may +have `compute()`): + +- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, + `transform`, `parse`. + +When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`; `Email::parse()` +is fine in a parser library but suspicious elsewhere (use `Email::from()` instead). + +### Architectural roles (allowed with justification) + +These names describe a role the library offers as a building block. Acceptable when the class **is** that role +(e.g., `EventHandler` in an events library, `CacheManager` in a cache library, `Upcaster` in an event-sourcing +library). Not acceptable on domain objects inside the library (value objects, enums, contract interfaces): + +- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`. + +The test: if the consumer instantiates or extends this class to integrate with the library, the role name is +legitimate. If the class models a concept the consumer manipulates (a money amount, a country code, a color), +the role name is wrong. + +## Value objects + +1. Are immutable: no setters, no mutation after construction. Operations return new instances. +2. Compare by value, not by reference. +3. Validate invariants in the constructor and throw on invalid input. +4. Have no identity field. +5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation paths + exist. The factory name communicates the semantic intent. + +## Exceptions + +1. Every failure throws a **dedicated exception class** named after the invariant it guards — never + `throw new DomainException('...')`, `throw new InvalidArgumentException('...')`, + `throw new RuntimeException('...')`, or any other generic native exception thrown directly. If the invariant + is worth throwing for, it is worth a named class. +2. Dedicated exception classes **extend** the appropriate native PHP exception (`DomainException`, + `InvalidArgumentException`, `OverflowException`, etc.) — the native class is the parent, never the thing that + is thrown. Consumers that catch the broad standard types continue to work; consumers that need precise handling + can catch the specific classes. +3. Exceptions are pure: no transport-specific fields (`code` populated with HTTP status, formatted `message` meant + for end-user display). Formatting to any transport happens at the consumer's boundary, not inside the library. +4. Exceptions signal invariant violations only, not control flow. +5. Name the class after the invariant violated, never after the technical type: + - `PrecisionOutOfRange` — not `InvalidPrecisionException`. + - `CurrencyMismatch` — not `BadCurrencyException`. + - `ContainerWaitTimeout` — not `TimeoutException`. +6. A descriptive `message` argument is allowed and encouraged when it carries **debugging context** — the violating + value, the boundary that was crossed, the state the library was in. The class name identifies the invariant; + the message describes the specific violation for stack traces and test assertions. Do not build messages meant + for end-user display or transport rendering. Keep them short, factual, and in American English. +7. Public exceptions live in `src/Exceptions/`. Internal exceptions live in `src/Internal/Exceptions/`. + +**Prohibited** — throwing a native exception directly: + +```php +if ($value < 0) { + throw new InvalidArgumentException('Precision cannot be negative.'); +} +``` + +**Correct** — dedicated class, no message (class name is sufficient): + +```php +// src/Exceptions/PrecisionOutOfRange.php +final class PrecisionOutOfRange extends InvalidArgumentException +{ +} + +// at the callsite +if ($value < 0) { + throw new PrecisionOutOfRange(); +} +``` + +**Correct** — dedicated class with debugging context: + +```php +if ($value < 0 || $value > 16) { + throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value)); +} +``` + +## Enums + +1. Are PHP backed enums. +2. Include methods when they carry vocabulary meaning (e.g., `Order::ASCENDING_KEY`, `RoundingMode::apply()`). +3. Live at the `src/` root when public. Enums used only by internals live in `src/Internal/`. + +## Extension points + +1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` instead + of `final readonly class`. All other classes use `final readonly class`. +2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) + as the only creation path. +3. Internal state is injected via the constructor and stored in a `private readonly` property. + +## Time and space complexity + +1. Every public method has predictable, documented complexity. Document Big O in PHPDoc on the interface + (see `php-library-code-style.md`, "PHPDoc" section). +2. Algorithms run in `O(N)` or `O(N log N)` unless the problem inherently requires worse. `O(N²)` or worse must + be justified and documented. +3. Prefer lazy/streaming evaluation over materializing intermediate results. In pipeline-style libraries, fuse + stages so a single pass suffices. +4. Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. +5. Validate complexity claims with benchmarks against a reference implementation when optimizing critical paths. + Parity testing against the reference library is the validation standard for optimization work. diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md new file mode 100644 index 0000000..610b928 --- /dev/null +++ b/.claude/rules/php-library-testing.md @@ -0,0 +1,116 @@ +--- +description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +paths: + - "tests/**/*.php" +--- + +# Testing conventions + +Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to +test files. + +## Structure: Given/When/Then (BDD) + +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. + +### Happy path example + +```php +public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void +{ + /** @Given two money instances in the same currency */ + $ten = Money::of(amount: 1000, currency: Currency::BRL); + $five = Money::of(amount: 500, currency: Currency::BRL); + + /** @When adding them together */ + $total = $ten->add(other: $five); + + /** @Then the result contains the sum of both amounts */ + self::assertEquals(expected: 1500, actual: $total->amount()); +} +``` + +### Exception example + +When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this +ordering. + +```php +public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void +{ + /** @Given two money instances in different currencies */ + $brl = Money::of(amount: 1000, currency: Currency::BRL); + $usd = Money::of(amount: 500, currency: Currency::USD); + + /** @Then an exception indicating currency mismatch should be thrown */ + $this->expectException(CurrencyMismatch::class); + + /** @When trying to add money with different currencies */ + $brl->add(other: $usd); +} +``` + +Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or +`@When` tags. + +## Rules + +1. Include exactly one `@When` per test. Two actions require two tests. +2. Test only the public API. Never assert on private state or `Internal/` classes directly. +3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, + clock, network) when the library interacts with external resources. +4. Name tests to describe behavior, not method names. +5. Never include conditional logic inside tests. +6. Include one logical concept per `@Then` block. +7. Maintain strict independence between tests. No inherited state. +8. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts + (e.g., `Amount`, `Invoice`, `Order`). +9. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +10. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class + for an internal model only when the condition cannot be reached through the public API. +11. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. +12. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, etc.). Pass arguments positionally. + +## Test setup and fixtures + +1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line + followed by one expression or assignment. Never place multiple variable declarations or object + constructions under a single annotation. +2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the + call site. Chain method calls when the intermediate state is not referenced elsewhere + (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). +3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. + If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a + private method on the test class. +4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object + represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like + `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. +5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. The annotation describes the domain + scenario, not the technical setup. + +## Test organization + +``` +tests/ +├── Models/ # Domain-specific fixtures reused across tests +├── Mocks/ # Test doubles for system boundaries +├── Unit/ # Unit tests for public API +│ └── Mocks/ # Alternative location for test doubles +├── Integration/ # Tests requiring real external resources (Docker, filesystem) +└── bootstrap.php # Test bootstrap when needed +``` + +`tests/Integration/` is only present when the library interacts with infrastructure. + +## Coverage and mutation testing + +1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration + that exclude code from coverage are allowed. +2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` + or any other mechanism. +3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor + the code to make it testable, do not work around the tool. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..73e3c9a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index 8c85471..744a43b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,22 @@ -/tests export-ignore -/vendor export-ignore +* text=auto eol=lf -/LICENSE export-ignore -/Makefile export-ignore -/README.md export-ignore -/phpunit.xml export-ignore -/phpstan.neon.dist export-ignore -/infection.json.dist export-ignore +*.php text diff=php -/.github export-ignore -/.gitignore export-ignore -/.gitattributes export-ignore +# Dev-only — excluded from the Packagist tarball +/.github export-ignore +/tests export-ignore +/.claude export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/phpcs.xml export-ignore +/phpcs.xml.dist export-ignore +/infection.json export-ignore +/infection.json.dist export-ignore +/Makefile export-ignore +/CONTRIBUTING.md export-ignore +/CHANGES.md export-ignore diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..77c2bb8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +# Copilot instructions + +## Context + +PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. + +## Mandatory pre-task step + +Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not +deviate from the patterns, folder structure, or naming conventions defined in them. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa78cef..71e59e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: bcmath tools: composer:2 - name: Validate composer.json @@ -52,7 +51,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: bcmath tools: composer:2 - name: Download vendor artifact from build @@ -77,7 +75,6 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ env.PHP_VERSION }} - extensions: bcmath tools: composer:2 - name: Download vendor artifact from build diff --git a/.gitignore b/.gitignore index 42b841a..bd5baa3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,20 @@ -.idea +# Agent/IDE +.claude/ +.idea/ +.vscode/ +.cursor/ -vendor -report -.phpunit.* +# Composer +/vendor/ +composer.lock -*.lock +# PHPUnit / coverage +.phpunit.cache/ +.phpunit.result.cache +report/ +coverage/ +build/ + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile index ef9a884..07acc3b 100644 --- a/Makefile +++ b/Makefile @@ -17,13 +17,14 @@ YELLOW := \033[0;33m .PHONY: configure configure: ## Configure development environment @${DOCKER_RUN} composer update --optimize-autoloader + @${DOCKER_RUN} composer normalize .PHONY: test test: ## Run all tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-file -test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file) +test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) @${DOCKER_RUN} composer test-file ${FILE} .PHONY: test-no-coverage @@ -38,6 +39,10 @@ review: ## Run static code analysis show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html +.PHONY: show-outdated +show-outdated: ## Show outdated direct dependencies + @${DOCKER_RUN} composer outdated --direct + .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} @@ -60,7 +65,7 @@ help: ## Display this help message | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" - @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" diff --git a/README.md b/README.md index c6f2f99..8172db6 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,14 @@ * [License](#license) * [Contributing](#contributing) -
+
## Overview -Provides a simple and flexible solution for managing environment variables, with easy access, type conversions, and -validation handling. +Provides a type-safe environment variable reader for PHP, wrapping raw values behind a typed accessor with explicit +string, integer, and boolean conversion methods. Supports defaults for missing variables and distinguishes between +absent and empty states. Built to surface configuration errors at read time rather than propagate silent coercions +through the system.
@@ -32,16 +34,20 @@ composer require tiny-blocks/environment-variable To create and work with environment variables, use the `from` method to get an instance of the environment variable. ```php +use TinyBlocks\EnvironmentVariable\EnvironmentVariable; + EnvironmentVariable::from(name: 'MY_VAR'); ``` To retrieve an environment variable with the option of providing a default value in case the variable does not exist, use the `fromOrDefault` method. -If the environment variable is not found, the method will return the provided default value instead of throwing an -exception. +If the environment variable is not found, the method returns an instance carrying the provided default value instead +of throwing an exception. ```php +use TinyBlocks\EnvironmentVariable\EnvironmentVariable; + EnvironmentVariable::fromOrDefault(name: 'MY_VAR', defaultValueIfNotFound: 'default_value'); ``` @@ -54,6 +60,8 @@ Once you have an instance of the environment variable, you can convert its value To convert the environment variable to a string. ```php +use TinyBlocks\EnvironmentVariable\EnvironmentVariable; + $environmentVariable = EnvironmentVariable::from(name: 'MY_VAR'); $environmentVariable->toString(); ``` @@ -63,6 +71,8 @@ $environmentVariable->toString(); To convert the environment variable to an integer. ```php +use TinyBlocks\EnvironmentVariable\EnvironmentVariable; + $environmentVariable = EnvironmentVariable::from(name: 'MY_VAR'); $environmentVariable->toInteger(); ``` @@ -72,6 +82,8 @@ $environmentVariable->toInteger(); To convert the environment variable to a boolean. ```php +use TinyBlocks\EnvironmentVariable\EnvironmentVariable; + $environmentVariable = EnvironmentVariable::from(name: 'MY_VAR'); $environmentVariable->toBoolean(); ``` @@ -81,11 +93,13 @@ $environmentVariable->toBoolean(); Checks if the environment variable has a value. Values like `false`, `0`, and `-1` are valid and non-empty. ```php +use TinyBlocks\EnvironmentVariable\EnvironmentVariable; + $environmentVariable = EnvironmentVariable::from(name: 'MY_VAR'); $environmentVariable->hasValue(); ``` -
+
## License diff --git a/composer.json b/composer.json index 3cb2970..0b9d043 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,31 @@ { "name": "tiny-blocks/environment-variable", - "type": "library", + "description": "Provides a type-safe environment variable reader for PHP, with strict integer and boolean conversion.", "license": "MIT", - "homepage": "https://github.com/tiny-blocks/environment-variable", - "description": "Provides a simple and flexible solution for managing environment variables, with easy access, type conversions, and validation handling.", - "prefer-stable": true, - "minimum-stability": "stable", - "keywords": [ - "psr", - "env", - "tiny-blocks", - "environment-variable" - ], + "type": "library", "authors": [ { "name": "Gustavo Freze de Araujo Santos", "homepage": "https://github.com/gustavofreze" } ], + "homepage": "https://github.com/tiny-blocks/environment-variable", "support": { "issues": "https://github.com/tiny-blocks/environment-variable/issues", "source": "https://github.com/tiny-blocks/environment-variable" }, - "config": { - "sort-packages": true, - "allow-plugins": { - "infection/extension-installer": true - } + "require": { + "php": "^8.5" }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51", + "infection/infection": "^0.32", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^13.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "minimum-stability": "stable", + "prefer-stable": true, "autoload": { "psr-4": { "TinyBlocks\\EnvironmentVariable\\": "src/" @@ -35,30 +33,27 @@ }, "autoload-dev": { "psr-4": { - "TinyBlocks\\EnvironmentVariable\\": "tests/" + "Test\\TinyBlocks\\EnvironmentVariable\\": "tests/" } }, - "require": { - "php": "^8.5" - }, - "require-dev": { - "phpunit/phpunit": "^11.5", - "phpstan/phpstan": "^2.1", - "dg/bypass-finals": "^1.8", - "infection/infection": "^0.32", - "squizlabs/php_codesniffer": "^4.0" + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "sort-packages": true }, "scripts": { - "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", - "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", - "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", "review": [ "@phpcs", "@phpstan" ], + "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", "tests": [ "@test", "@mutation-test" diff --git a/infection.json.dist b/infection.json.dist index 45c49fc..ee435dd 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -16,7 +16,8 @@ "customPath": "./vendor/bin/phpunit" }, "mutators": { - "@default": true + "@default": true, + "ProtectedVisibility": false }, "minCoveredMsi": 100, "testFramework": "phpunit" diff --git a/src/Environment.php b/src/Environment.php index 1b27ba8..9665750 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -4,8 +4,9 @@ namespace TinyBlocks\EnvironmentVariable; +use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotBoolean; +use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotInteger; use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentVariableMissing; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\InvalidEnvironmentValue; /** * Provides methods to handling environment variables. @@ -48,7 +49,7 @@ public function toString(): string; * Converts the environment variable value to an integer. * * @return int The environment variable value as an integer. - * @throws InvalidEnvironmentValue If the value cannot be converted to an integer. + * @throws EnvironmentValueNotInteger If the value cannot be converted to an integer. */ public function toInteger(): int; @@ -56,7 +57,7 @@ public function toInteger(): int; * Converts the environment variable value to a boolean. * * @return bool The environment variable value as a boolean. - * @throws InvalidEnvironmentValue If the value cannot be converted to a boolean. + * @throws EnvironmentValueNotBoolean If the value cannot be converted to a boolean. */ public function toBoolean(): bool; } diff --git a/src/EnvironmentVariable.php b/src/EnvironmentVariable.php index 858ba0b..268f383 100644 --- a/src/EnvironmentVariable.php +++ b/src/EnvironmentVariable.php @@ -4,8 +4,10 @@ namespace TinyBlocks\EnvironmentVariable; +use TinyBlocks\EnvironmentVariable\Internal\EnvironmentSource; +use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotBoolean; +use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotInteger; use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentVariableMissing; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\InvalidEnvironmentValue; final readonly class EnvironmentVariable implements Environment { @@ -15,20 +17,18 @@ private function __construct(private string $value, private string $variable) public static function from(string $name): EnvironmentVariable { - $environmentVariable = getenv($name); + $environmentVariable = EnvironmentSource::lookup(name: $name); - return $environmentVariable === false + return is_null($environmentVariable) ? throw new EnvironmentVariableMissing(variable: $name) : new EnvironmentVariable(value: $environmentVariable, variable: $name); } public static function fromOrDefault(string $name, ?string $defaultValueIfNotFound = null): EnvironmentVariable { - $environmentVariable = getenv($name); + $environmentVariable = EnvironmentSource::lookup(name: $name) ?? $defaultValueIfNotFound ?? ''; - return $environmentVariable === false - ? new EnvironmentVariable(value: (string)$defaultValueIfNotFound, variable: $name) - : new EnvironmentVariable(value: $environmentVariable, variable: $name); + return new EnvironmentVariable(value: $environmentVariable, variable: $name); } public function hasValue(): bool @@ -46,9 +46,11 @@ public function toString(): string public function toInteger(): int { - return is_numeric($this->value) - ? (int)$this->value - : throw InvalidEnvironmentValue::fromIntegerConversion(value: $this->value, variable: $this->variable); + $filteredValue = filter_var($this->value, FILTER_VALIDATE_INT); + + return $filteredValue !== false + ? $filteredValue + : throw new EnvironmentValueNotInteger(variable: $this->variable); } public function toBoolean(): bool @@ -57,6 +59,6 @@ public function toBoolean(): bool return $filteredValue !== null ? $filteredValue - : throw InvalidEnvironmentValue::fromBooleanConversion(value: $this->value, variable: $this->variable); + : throw new EnvironmentValueNotBoolean(variable: $this->variable); } } diff --git a/src/Internal/EnvironmentSource.php b/src/Internal/EnvironmentSource.php new file mode 100644 index 0000000..499650b --- /dev/null +++ b/src/Internal/EnvironmentSource.php @@ -0,0 +1,23 @@ + is invalid for conversion to .'; + + parent::__construct(message: sprintf($template, $this->variable)); + } +} diff --git a/src/Internal/Exceptions/EnvironmentValueNotInteger.php b/src/Internal/Exceptions/EnvironmentValueNotInteger.php new file mode 100644 index 0000000..fabce0c --- /dev/null +++ b/src/Internal/Exceptions/EnvironmentValueNotInteger.php @@ -0,0 +1,17 @@ + is invalid for conversion to .'; + + parent::__construct(message: sprintf($template, $this->variable)); + } +} diff --git a/src/Internal/Exceptions/InvalidEnvironmentValue.php b/src/Internal/Exceptions/InvalidEnvironmentValue.php deleted file mode 100644 index fbc3903..0000000 --- a/src/Internal/Exceptions/InvalidEnvironmentValue.php +++ /dev/null @@ -1,30 +0,0 @@ - for environment variable <%s> is invalid for conversion to <%s>.'; - - parent::__construct(message: sprintf($template, $this->value, $this->variable, $this->conversionType)); - } - - public static function fromIntegerConversion(string $value, string $variable): InvalidEnvironmentValue - { - return new InvalidEnvironmentValue(value: $value, variable: $variable, conversionType: 'integer'); - } - - public static function fromBooleanConversion(string $value, string $variable): InvalidEnvironmentValue - { - return new InvalidEnvironmentValue(value: $value, variable: $variable, conversionType: 'boolean'); - } -} diff --git a/tests/EnvironmentVariableTest.php b/tests/EnvironmentVariableTest.php index 97f0d66..a3e351d 100644 --- a/tests/EnvironmentVariableTest.php +++ b/tests/EnvironmentVariableTest.php @@ -2,157 +2,263 @@ declare(strict_types=1); -namespace TinyBlocks\EnvironmentVariable; +namespace Test\TinyBlocks\EnvironmentVariable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use TinyBlocks\EnvironmentVariable\EnvironmentVariable; +use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotBoolean; +use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentValueNotInteger; use TinyBlocks\EnvironmentVariable\Internal\Exceptions\EnvironmentVariableMissing; -use TinyBlocks\EnvironmentVariable\Internal\Exceptions\InvalidEnvironmentValue; final class EnvironmentVariableTest extends TestCase { - #[DataProvider('stringConversionDataProvider')] - public function testConvertToString(mixed $value, string $variable, string $expected): void + private const array MANAGED_VARIABLES = [ + 'MY_VAR', + 'VALID_INT', + 'INVALID_INT', + 'INVALID_BOOL', + 'NON_EXISTENT', + 'NULL_VALUE', + 'EMPTY_STRING', + 'VALID_STRING', + 'NEGATIVE_INT', + 'NUMERIC_TRUE', + 'BOOLEAN_TRUE', + 'STRING_VALUE', + 'INTEGER_ZERO', + 'NUMERIC_FALSE', + 'BOOLEAN_FALSE', + 'NON_SCALAR_ENV', + 'NUMERIC_STRING', + 'NON_EXISTENT_VAR', + 'INTEGER_POSITIVE', + 'INTEGER_NEGATIVE', + 'NON_SCALAR_SERVER', + 'STRING_WITH_SPACES', + 'FROM_ENV_SUPERGLOBAL', + 'NON_EXISTENT_MY_VAR', + 'FROM_SERVER_SUPERGLOBAL' + ]; + + protected function tearDown(): void { - /** @Given the environment variable is set with the given string value */ + foreach (self::MANAGED_VARIABLES as $variable) { + putenv($variable); + unset($_ENV[$variable], $_SERVER[$variable]); + } + } + + #[DataProvider('stringConversionDataProvider')] + public function testToStringWhenValuePresentThenReturnsExpectedString( + mixed $value, + string $variable, + string $expected + ): void { + /** @Given the environment variable is set with the given raw value */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When I try to convert the environment variable value to a string */ + /** @When converting the environment variable to string */ $actual = EnvironmentVariable::from(name: $variable)->toString(); - /** @Then the result should match the expected string value */ - self::assertEquals($expected, $actual); + /** @Then the returned string matches the expected representation */ + self::assertSame($expected, $actual); } #[DataProvider('integerConversionDataProvider')] - public function testConvertToInteger(mixed $value, string $variable, int $expected): void - { - /** @Given the environment variable is set with the given integer value */ + public function testToIntegerWhenValueIsNumericThenReturnsExpectedInteger( + string $value, + string $variable, + int $expected + ): void { + /** @Given the environment variable is set with a numeric string */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When I try to convert the environment variable value to an integer */ + /** @When converting the environment variable to integer */ $actual = EnvironmentVariable::from(name: $variable)->toInteger(); - /** @Then the result should match the expected integer value */ - self::assertEquals($expected, $actual); + /** @Then the returned integer matches the expected value */ + self::assertSame($expected, $actual); } #[DataProvider('booleanConversionDataProvider')] - public function testConvertToBoolean(mixed $value, string $variable, bool $expected): void - { - /** @Given the environment variable is set with the given boolean value */ + public function testToBooleanWhenValueIsBooleanLikeThenReturnsExpectedBoolean( + string $value, + string $variable, + bool $expected + ): void { + /** @Given the environment variable is set with a boolean-like value */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When I try to convert the environment variable value to a boolean */ + /** @When converting the environment variable to boolean */ $actual = EnvironmentVariable::from(name: $variable)->toBoolean(); - /** @Then the result should match the expected boolean value */ - self::assertEquals($expected, $actual); + /** @Then the returned boolean matches the expected value */ + self::assertSame($expected, $actual); } - public function testFromOrDefaultWithDefaultValue(): void + public function testFromOrDefaultWhenVariableMissingThenReturnsDefault(): void { - /** @Given that the environment variable 'NON_EXISTENT_MY_VAR' does not exist */ + /** @Given the environment variable does not exist */ $variable = 'NON_EXISTENT_MY_VAR'; - /** @When I try to get the value of the environment variable with a default value */ + /** @When requesting the variable with a default value */ $actual = EnvironmentVariable::fromOrDefault(name: $variable, defaultValueIfNotFound: '0'); - /** @Then the result should match the default value */ - self::assertEquals(0, $actual->toInteger()); + /** @Then the returned instance exposes the default value */ + self::assertSame(0, $actual->toInteger()); } - public function testFromOrDefaultWithExistingVariable(): void + public function testFromOrDefaultWhenVariableExistsThenReturnsExistingValue(): void { - /** @Given that the environment variable 'MY_VAR' exists with the value 'existing_value' */ + /** @Given the environment variable exists with an existing value */ putenv(sprintf('%s=%s', 'MY_VAR', 'existing_value')); - /** @When I try to get the value of the environment variable */ + /** @When requesting the variable with a default value */ $actual = EnvironmentVariable::fromOrDefault(name: 'MY_VAR', defaultValueIfNotFound: 'default_value'); - /** @Then the result should match the existing value */ - self::assertEquals('existing_value', $actual->toString()); + /** @Then the returned instance exposes the existing value */ + self::assertSame('existing_value', $actual->toString()); } - public function testFromOrDefaultWhenVariableIsMissingAndNoDefault(): void + public function testFromOrDefaultWhenVariableMissingAndNoDefaultThenToStringIsEmpty(): void { - /** @Given that the environment variable 'NON_EXISTENT_VAR' does not exist */ + /** @Given the environment variable does not exist */ $variable = 'NON_EXISTENT_VAR'; - /** @When I try to get the value of the missing environment variable without a default value */ + /** @When requesting the variable without a default value */ $actual = EnvironmentVariable::fromOrDefault(name: $variable); - /** @Then the result should be no value */ - self::assertEmpty($actual->toString()); + /** @Then the returned instance exposes an empty string */ + self::assertSame('', $actual->toString()); + } + + public function testFromOrDefaultWhenVariableMissingAndNoDefaultThenHasValueIsFalse(): void + { + /** @Given the environment variable does not exist */ + $variable = 'NON_EXISTENT_VAR'; + + /** @When requesting the variable without a default value */ + $actual = EnvironmentVariable::fromOrDefault(name: $variable); + + /** @Then the returned instance reports no value */ self::assertFalse($actual->hasValue()); } #[DataProvider('hasValueDataProvider')] - public function testHasValue(mixed $value, string $variable): void + public function testHasValueWhenValueIsMeaningfulThenReturnsTrue(string $value, string $variable): void { - /** @Given the environment variable is set with the given value */ + /** @Given the environment variable is set with a meaningful value */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When I check if the environment variable has a value */ + /** @When checking if the environment variable has a value */ $actual = EnvironmentVariable::from(name: $variable)->hasValue(); - /** @Then the result should be true (has value) */ + /** @Then the check reports the presence of a value */ self::assertTrue($actual); } #[DataProvider('hasNoValueDataProvider')] - public function testHasNoValue(mixed $value, string $variable): void + public function testHasValueWhenValueIsAbsentOrNullLikeThenReturnsFalse(?string $value, string $variable): void { - /** @Given the environment variable is set with the given value */ + /** @Given the environment variable is set with a null-like value */ putenv(sprintf('%s=%s', $variable, $value)); - /** @When I check if the environment variable has a value */ + /** @When checking if the environment variable has a value */ $actual = EnvironmentVariable::from(name: $variable)->hasValue(); - /** @Then the result should be false (no value) */ + /** @Then the check reports the absence of a value */ self::assertFalse($actual); } - public function testExceptionWhenVariableIsMissing(): void + public function testFromWhenVariableIsMissingThenThrowsEnvironmentVariableMissing(): void { - /** @Given that the environment variable 'NON_EXISTENT' does not exist */ + /** @Given the environment variable does not exist */ $variable = 'NON_EXISTENT'; - /** @Then an error indicating the variable is missing should occur */ + /** @Then a missing environment variable exception is expected */ $this->expectException(EnvironmentVariableMissing::class); $this->expectExceptionMessage('Environment variable is missing.'); - /** @When I try to get the value of the missing environment variable */ + /** @When requesting the missing environment variable */ EnvironmentVariable::from(name: $variable); } - public function testExceptionWhenInvalidIntegerConversion(): void + public function testFromWhenScalarPresentInEnvSuperglobalThenValueIsCoerced(): void { - /** @Given that the environment variable 'INVALID_INT' has an invalid integer value */ + /** @Given a non-string scalar available only in $_ENV */ + $_ENV['FROM_ENV_SUPERGLOBAL'] = 42; + + /** @When reading the environment variable */ + $actual = EnvironmentVariable::from(name: 'FROM_ENV_SUPERGLOBAL')->toString(); + + /** @Then the value from $_ENV is coerced to string */ + self::assertSame('42', $actual); + } + + public function testFromWhenScalarPresentInServerSuperglobalThenValueIsCoerced(): void + { + /** @Given a non-string scalar available only in $_SERVER */ + $_SERVER['FROM_SERVER_SUPERGLOBAL'] = 7; + + /** @When reading the environment variable */ + $actual = EnvironmentVariable::from(name: 'FROM_SERVER_SUPERGLOBAL')->toString(); + + /** @Then the value from $_SERVER is coerced to string */ + self::assertSame('7', $actual); + } + + public function testFromWhenNonScalarInEnvSuperglobalThenThrowsMissing(): void + { + /** @Given a non-scalar entry in $_ENV */ + $_ENV['NON_SCALAR_ENV'] = ['nested' => 'value']; + + /** @Then a missing environment variable exception is expected */ + $this->expectException(EnvironmentVariableMissing::class); + + /** @When reading the environment variable */ + EnvironmentVariable::from(name: 'NON_SCALAR_ENV'); + } + + public function testFromWhenNonScalarInServerSuperglobalThenThrowsMissing(): void + { + /** @Given a non-scalar entry in $_SERVER */ + $_SERVER['NON_SCALAR_SERVER'] = ['nested' => 'value']; + + /** @Then a missing environment variable exception is expected */ + $this->expectException(EnvironmentVariableMissing::class); + + /** @When reading the environment variable */ + EnvironmentVariable::from(name: 'NON_SCALAR_SERVER'); + } + + public function testToIntegerWhenValueIsNotNumericThenThrowsEnvironmentValueNotInteger(): void + { + /** @Given the environment variable holds a non-numeric value */ putenv(sprintf('%s=%s', 'INVALID_INT', 'invalid-value')); - /** @Then an error indicating the value cannot be converted to an integer should occur */ - $this->expectException(InvalidEnvironmentValue::class); + /** @Then an invalid integer conversion exception is expected */ + $this->expectException(EnvironmentValueNotInteger::class); $this->expectExceptionMessage( - 'The value for environment variable is invalid for conversion to .' + 'The value for environment variable is invalid for conversion to .' ); - /** @When I try to convert the invalid value to an integer */ + /** @When converting the environment variable to integer */ EnvironmentVariable::from(name: 'INVALID_INT')->toInteger(); } - public function testExceptionWhenInvalidBooleanConversion(): void + public function testToBooleanWhenValueIsNotBooleanLikeThenThrowsEnvironmentValueNotBoolean(): void { - /** @Given that the environment variable 'INVALID_BOOL' has an invalid boolean value */ + /** @Given the environment variable holds a non-boolean-like value */ putenv(sprintf('%s=%s', 'INVALID_BOOL', 'invalid-value')); - /** @Then an error indicating the value cannot be converted to a boolean should occur */ - $this->expectException(InvalidEnvironmentValue::class); + /** @Then an invalid boolean conversion exception is expected */ + $this->expectException(EnvironmentValueNotBoolean::class); $this->expectExceptionMessage( - 'The value for environment variable is invalid for conversion to .' + 'The value for environment variable is invalid for conversion to .' ); - /** @When I try to convert the invalid value to a boolean */ + /** @When converting the environment variable to boolean */ EnvironmentVariable::from(name: 'INVALID_BOOL')->toBoolean(); } @@ -185,20 +291,20 @@ public static function stringConversionDataProvider(): array public static function integerConversionDataProvider(): array { return [ - 'Float value' => [ - 'value' => '99.99', - 'variable' => 'FLOAT_VALUE', - 'expected' => 99 - ], - 'Integer value' => [ + 'Integer value' => [ 'value' => '123', 'variable' => 'VALID_INT', 'expected' => 123 ], - 'Numeric string' => [ + 'Numeric string' => [ 'value' => '42', 'variable' => 'NUMERIC_STRING', 'expected' => 42 + ], + 'Negative integer' => [ + 'value' => '-7', + 'variable' => 'NEGATIVE_INT', + 'expected' => -7 ] ]; }