Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.51% covered (success)
98.51%
66 / 67
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangelogDocument
98.51% covered (success)
98.51%
66 / 67
88.89% covered (warning)
88.89%
8 / 9
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReleases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUnreleased
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getRelease
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getLatestPublishedRelease
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 withRelease
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 promoteUnreleased
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 normalizeUnreleasedPosition
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Changelog\Document;
21
22use FastForward\DevTools\Changelog\Entry\ChangelogEntryType;
23
24/**
25 * Represents the minimal Keep a Changelog document structure used by dev-tools.
26 */
27  class ChangelogDocument
28{
29    public const string UNRELEASED_VERSION = 'Unreleased';
30
31    /**
32     * @param list<ChangelogRelease> $releases
33     */
34    public function __construct(
35        private array $releases,
36    ) {}
37
38    /**
39     * Creates a new document with an empty Unreleased section.
40     */
41    public static function create(): self
42    {
43        return new self([new ChangelogRelease(self::UNRELEASED_VERSION)]);
44    }
45
46    /**
47     * Returns the release sections in document order.
48     *
49     * @return list<ChangelogRelease>
50     */
51    public function getReleases(): array
52    {
53        return $this->releases;
54    }
55
56    /**
57     * Returns the Unreleased section, creating an empty one when needed.
58     */
59    public function getUnreleased(): ChangelogRelease
60    {
61        foreach ($this->releases as $release) {
62            if ($release->isUnreleased()) {
63                return $release;
64            }
65        }
66
67        return new ChangelogRelease(self::UNRELEASED_VERSION);
68    }
69
70    /**
71     * Returns the requested release section when present.
72     *
73     * @param string $version
74     */
75    public function getRelease(string $version): ?ChangelogRelease
76    {
77        foreach ($this->releases as $release) {
78            if ($release->getVersion() === $version) {
79                return $release;
80            }
81        }
82
83        return null;
84    }
85
86    /**
87     * Returns the newest published release section when available.
88     */
89    public function getLatestPublishedRelease(): ?ChangelogRelease
90    {
91        foreach ($this->releases as $release) {
92            if (! $release->isUnreleased()) {
93                return $release;
94            }
95        }
96
97        return null;
98    }
99
100    /**
101     * Returns a copy with the provided release inserted or replaced.
102     *
103     * @param ChangelogRelease $target
104     */
105    public function withRelease(ChangelogRelease $target): self
106    {
107        $releases = [];
108        $replaced = false;
109
110        foreach ($this->releases as $release) {
111            if ($release->getVersion() === $target->getVersion()) {
112                $releases[] = $target;
113                $replaced = true;
114
115                continue;
116            }
117
118            $releases[] = $release;
119        }
120
121        if (! $replaced) {
122            if ($target->isUnreleased()) {
123                array_unshift($releases, $target);
124            } else {
125                $inserted = false;
126
127                foreach ($releases as $index => $release) {
128                    if ($release->isUnreleased()) {
129                        continue;
130                    }
131
132                    array_splice($releases, $index, 0, [$target]);
133                    $inserted = true;
134
135                    break;
136                }
137
138                if (! $inserted) {
139                    $releases[] = $target;
140                }
141            }
142        }
143
144        return new self($this->normalizeUnreleasedPosition($releases));
145    }
146
147    /**
148     * Returns a copy with the unreleased entries promoted into a published release.
149     *
150     * @param string $version
151     * @param string $date
152     */
153    public function promoteUnreleased(string $version, string $date): self
154    {
155        $unreleased = $this->getUnreleased();
156
157        $promoted = new ChangelogRelease($version, $date, $unreleased->getEntries());
158        $currentVersion = $this->getRelease($version);
159
160        if ($currentVersion instanceof ChangelogRelease) {
161            $mergedEntries = $currentVersion->getEntries();
162
163            foreach (ChangelogEntryType::ordered() as $type) {
164                $mergedEntries[$type->value] = array_values(array_unique([
165                    ...$currentVersion->getEntriesFor($type),
166                    ...$unreleased->getEntriesFor($type),
167                ]));
168            }
169
170            $promoted = new ChangelogRelease($version, $date, $mergedEntries);
171        }
172
173        $releases = [];
174
175        foreach ($this->releases as $release) {
176            if ($release->isUnreleased()) {
177                $releases[] = new ChangelogRelease(self::UNRELEASED_VERSION);
178                $releases[] = $promoted;
179
180                continue;
181            }
182
183            if ($release->getVersion() === $version) {
184                continue;
185            }
186
187            $releases[] = $release;
188        }
189
190        if ([] === $releases) {
191            $releases = [new ChangelogRelease(self::UNRELEASED_VERSION), $promoted];
192        }
193
194        return new self($this->normalizeUnreleasedPosition($releases));
195    }
196
197    /**
198     * Ensures the Unreleased section stays first in the document.
199     *
200     * @param list<ChangelogRelease> $releases
201     *
202     * @return list<ChangelogRelease>
203     */
204    private function normalizeUnreleasedPosition(array $releases): array
205    {
206        $unreleased = null;
207        $published = [];
208
209        foreach ($releases as $release) {
210            if ($release->isUnreleased()) {
211                $unreleased ??= $release;
212
213                continue;
214            }
215
216            $published[] = $release;
217        }
218
219        return [$unreleased ?? new ChangelogRelease(self::UNRELEASED_VERSION), ...$published];
220    }
221}