Lines 100.00% 47 / 47
Functions and Methods 100.00% 18 / 18
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Duration 100.00% 47 / 47 100.00% 18 / 18 31 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 zero 100.00% 1 / 1 100.00% 1 / 1 1
 microseconds 100.00% 3 / 3 100.00% 1 / 1 2
 milliseconds 100.00% 1 / 1 100.00% 1 / 1 1
 seconds 100.00% 1 / 1 100.00% 1 / 1 1
 minutes 100.00% 1 / 1 100.00% 1 / 1 1
 hours 100.00% 1 / 1 100.00% 1 / 1 1
 parse 100.00% 7 / 7 100.00% 1 / 1 5
 requireAtLeastOneMillisecond 100.00% 3 / 3 100.00% 1 / 1 2
 toMicroseconds 100.00% 1 / 1 100.00% 1 / 1 1
 toMilliseconds 100.00% 1 / 1 100.00% 1 / 1 1
 toSeconds 100.00% 1 / 1 100.00% 1 / 1 1
 plus 100.00% 3 / 3 100.00% 1 / 1 2
 isLongerThan 100.00% 1 / 1 100.00% 1 / 1 1
 equals 100.00% 1 / 1 100.00% 1 / 1 1
 formatAsClock 100.00% 4 / 4 100.00% 1 / 1 2
 __toString 100.00% 10 / 10 100.00% 1 / 1 4
 scale 100.00% 6 / 6 100.00% 1 / 1 3
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Contracts\Time;
6
7use Stewart\Contracts\Exception\TimeError;
8use Stewart\Contracts\Exception\TimeException;
9use Stringable;
10
11final readonly class Duration implements Stringable
12{
13    private const array UNITS = ['h' => 3_600_000_000, 'm' => 60_000_000, 's' => 1_000_000, 'ms' => 1_000, 'us' => 1];
14
15    private function __construct(private int $microseconds) {}
16
17    public static function zero(): self
18    {
19        return new self(0);
20    }
21
22    /** @throws TimeException */
23    public static function microseconds(int $microseconds): self
24    {
25        if ($microseconds < 0) {
26            throw TimeException::createForReason(TimeError::DurationNegative, microseconds: $microseconds);
27        }
28
29        return new self($microseconds);
30    }
31
32    /** @throws TimeException */
33    public static function milliseconds(int|float $milliseconds): self
34    {
35        return self::microseconds(self::scale($milliseconds, self::UNITS['ms']));
36    }
37
38    /** @throws TimeException */
39    public static function seconds(int|float $seconds): self
40    {
41        return self::microseconds(self::scale($seconds, self::UNITS['s']));
42    }
43
44    /** @throws TimeException */
45    public static function minutes(int|float $minutes): self
46    {
47        return self::microseconds(self::scale($minutes, self::UNITS['m']));
48    }
49
50    /** @throws TimeException */
51    public static function hours(int|float $hours): self
52    {
53        return self::microseconds(self::scale($hours, self::UNITS['h']));
54    }
55
56    /** @throws TimeException */
57    public static function parse(string $text): self
58    {
59        $segments = preg_split('/\s+/', trim($text), flags: \PREG_SPLIT_NO_EMPTY);
60        $microseconds = 0;
61
62        foreach ($segments === false || $segments === [] ? [$text] : $segments as $segment) {
63            if (preg_match('/\A(\d+(?:\.\d+)?)(us|ms|s|m|h)\z/', $segment, $match) !== 1) {
64                throw TimeException::createForReason(TimeError::DurationUnparsable, text: $text);
65            }
66
67            $microseconds += self::scale((float) $match[1], self::UNITS[$match[2]]);
68        }
69
70        return self::microseconds($microseconds);
71    }
72
73    /** @throws TimeException */
74    public function requireAtLeastOneMillisecond(string $usage): self
75    {
76        if ($this->microseconds < self::UNITS['ms']) {
77            throw TimeException::createForReason(TimeError::DurationNotPositive, usage: $usage);
78        }
79
80        return $this;
81    }
82
83    public function toMicroseconds(): int
84    {
85        return $this->microseconds;
86    }
87
88    public function toMilliseconds(): int
89    {
90        return intdiv($this->microseconds, self::UNITS['ms']);
91    }
92
93    public function toSeconds(): float
94    {
95        return $this->microseconds / self::UNITS['s'];
96    }
97
98    /** @throws TimeException */
99    public function plus(self $other): self
100    {
101        if ($this->microseconds > \PHP_INT_MAX - $other->microseconds) {
102            throw TimeException::createForReason(TimeError::DurationTooLarge);
103        }
104
105        return new self($this->microseconds + $other->microseconds);
106    }
107
108    public function isLongerThan(self $other): bool
109    {
110        return $this->microseconds > $other->microseconds;
111    }
112
113    public function equals(self $other): bool
114    {
115        return $this->microseconds === $other->microseconds;
116    }
117
118    public function formatAsClock(): string
119    {
120        $seconds = intdiv($this->microseconds, self::UNITS['s']);
121        $fraction = $this->microseconds % self::UNITS['s'];
122        $clock = \sprintf('%02d:%02d:%02d', intdiv($seconds, 3_600), intdiv($seconds % 3_600, 60), $seconds % 60);
123
124        return $fraction === 0 ? $clock : $clock . '.' . rtrim(\sprintf('%06d', $fraction), '0');
125    }
126
127    public function __toString(): string
128    {
129        if ($this->microseconds === 0) {
130            return '0s';
131        }
132
133        $parts = [];
134        $remaining = $this->microseconds;
135
136        foreach (self::UNITS as $unit => $size) {
137            $amount = intdiv($remaining, $size);
138            $remaining -= $amount * $size;
139
140            if ($amount > 0) {
141                $parts[] = $amount . $unit;
142            }
143        }
144
145        return implode(' ', $parts);
146    }
147
148    /** @throws TimeException */
149    private static function scale(int|float $amount, int $factor): int
150    {
151        if (!is_finite((float) $amount)) {
152            throw TimeException::durationNotFinite((float) $amount);
153        }
154
155        $scaled = round($amount * $factor);
156
157        if (abs($scaled) > \PHP_INT_MAX) {
158            throw TimeException::createForReason(TimeError::DurationTooLarge);
159        }
160
161        return (int) $scaled;
162    }
163}