Lines 100.00% 37 / 37
Methods 100.00% 6 / 6
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 of 100.00% 1 / 1 100.00% 1 / 1 1
 defaultMessage 100.00% 1 / 1 100.00% 1 / 1 1
 validate 100.00% 26 / 26 100.00% 1 / 1 9
 [Lucent\Validation\Constraint] withMessage 100.00% 2 / 2 100.00% 1 / 1 1
 [Lucent\Validation\Constraint] message 100.00% 6 / 6 100.00% 1 / 1 3
22final class One extends Constraint
23{
24    /**
25     * @param array<int, Constraint> $constraints The constraints to apply.
26     */
27    private function __construct(private readonly array $constraints) {}
28
29    /**
30     * Create a "one" combinator from one or more constraints.
31     *
32     * @param Constraint ...$constraints The constraints of which exactly one must pass.
33     * @return self A new One instance.
34     */
35    public static function of(Constraint ...$constraints): self
36    {
37        return new self($constraints);
38    }
39
40    /**
41     * No message of its own — the generic "exactly one" message and the
42     * matched constraints' messages are recorded directly on the result in
43     * {@see validate()}.
44     *
45     * @return string|\Closure(FieldContext): ?string|null Always null.
46     */
47    #[Override]
48    protected function defaultMessage(): string|\Closure|null
49    {
50        return null;
51    }
52
53    /**
54     * Validate that exactly one wrapped constraint passes.
55     *
56     * Each alternative is validated in isolation against a fresh, throwaway
57     * {@see Result}, so a losing branch's errors *and* values never leak into
58     * the final result. If exactly one passes, its result is committed via
59     * {@see Result::merge()} and the field passes. If more than one passes,
60     * the generic "must match exactly one" message is recorded first,
61     * followed by each matched constraint's message, so the user sees which
62     * rules the value matched (and therefore must not both match). If none
63     * pass, a single generic message is recorded so the caller sees one clear
64     * error rather than a pile-up of every failed alternative's errors.
65     *
66     * @param FieldContext $ctx The context of the field being validated.
67     * @return bool True if exactly one constraint passes, false otherwise.
68     */
69    #[Override]
70    public function validate(FieldContext $ctx): bool
71    {
72        $matched = [];
73        $branchResults = [];
74        $failed = [];
75
76        foreach ($this->constraints as $constraint) {
77            [$passed, $branch] = $ctx->branch($constraint);
78
79            if ($passed) {
80                $matched[] = $constraint;
81                $branchResults[] = $branch;
82            } else {
83                $failed[] = [$constraint, $branch];
84            }
85        }
86
87        if (count($matched) === 1) {
88            // Exactly one alternative passed — commit its result so the
89            // winning branch's normalized values are preserved.
90            $ctx->result->merge($branchResults[0]);
91            return true;
92        }
93
94        if (count($matched) > 1) {
95            // More than one matched — the field is invalid. Record the
96            // generic framing message first, then each matched constraint's
97            // message so the user sees which rules the value matched (and
98            // therefore must not both match).
99            $ctx->result->addError($ctx->field, "The {$ctx->field} must match exactly one of the given rules.");
100            foreach ($matched as $constraint) {
101                $message = $constraint->message($ctx);
102                if ($message !== null) {
103                    $ctx->result->addError($ctx->field, $message);
104                }
105            }
106            return false;
107        }
108
109        // No alternative matched — record the generic framing message first,
110        // then surface each failed alternative's specific errors so the
111        // caller sees the rules that were expected. Simple constraints (e.g.
112        // Length) report their message at the field path; composite ones
113        // (e.g. Shape) record child errors at their dotted paths.
114        $ctx->result->addError($ctx->field, "The {$ctx->field} must match exactly one of the given rules.");
115        foreach ($failed as [$constraint, $branch]) {
116            $ctx->result->merge($branch);
117
118            $message = $constraint->message($ctx);
119            if ($message !== null) {
120                $ctx->result->addError($ctx->field, $message);
121            }
122        }
123        return false;
124    }
125}

Inherited from Lucent\Validation\Constraint

38    final public function withMessage(string|\Closure $message): static
39    {
40        $this->customMessage = $message;
41        return $this;
42    }
56    final public function message(FieldContext $ctx): ?string
57    {
58        $message = $this->customMessage ?? $this->defaultMessage();
59
60        if ($message === null) {
61            return null;
62        }
63
64        if ($message instanceof \Closure) {
65            return call_user_func($message, $ctx);
66        }
67
68        return $message;
69    }